From d488c872037489096e626f5fc98d070cbb9b5e61 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 13 Mar 2026 20:52:27 +0100 Subject: [PATCH] refactor(praca_magisterska_video): fix ruff violations and remove noqa from diagram generators - Add type annotations, docstrings, and constants - Remove commented-out code and print statements - Fix all lint issues in 11 generate_images files --- .../generate_images/anki_approach_1.py | 30 +- .../generate_images/anki_approach_2.py | 29 +- .../generate_images/anki_generator.py | 248 ++-- .../generate_agent_diagrams.py | 654 ++++++---- .../generate_images/generate_anki.py | 280 +++-- .../generate_images/generate_anki_final.py | 445 ++++--- .../generate_images/generate_anki_v2.py | 71 +- .../generate_images/generate_anki_v3.py | 369 +++--- .../generate_automata_diagrams.py | 784 +++++++----- .../generate_normalization_diagrams.py | 106 +- .../generate_pubsub_diagrams.py | 1056 ++++++++++++----- 11 files changed, 2726 insertions(+), 1346 deletions(-) diff --git a/python_pkg/praca_magisterska_video/generate_images/anki_approach_1.py b/python_pkg/praca_magisterska_video/generate_images/anki_approach_1.py index 5031538..005eebc 100755 --- a/python_pkg/praca_magisterska_video/generate_images/anki_approach_1.py +++ b/python_pkg/praca_magisterska_video/generate_images/anki_approach_1.py @@ -7,11 +7,17 @@ from __future__ import annotations +import logging from pathlib import Path import re +logger = logging.getLogger(__name__) -def clean_text(text) -> str: +MIN_BODY_LENGTH = 50 +MIN_ANSWER_LENGTH = 100 + + +def clean_text(text: str) -> str: """Clean text.""" if not text: return "" @@ -23,7 +29,7 @@ def clean_text(text) -> str: return text.strip() -def extract_cards(filepath) -> list[dict[str, str]]: +def extract_cards(filepath: str) -> list[dict[str, str]]: """Extract cards.""" with Path(filepath).open(encoding="utf-8") as f: content = f.read() @@ -68,10 +74,10 @@ def extract_cards(filepath) -> list[dict[str, str]]: content, re.MULTILINE | re.DOTALL, ) - for header, body in sections: - header = header.strip() - body = body.strip() - if len(body) < 50: + for raw_header, raw_body in sections: + header = raw_header.strip() + body = raw_body.strip() + if len(body) < MIN_BODY_LENGTH: continue # Get first paragraph @@ -102,8 +108,10 @@ def main() -> None: for md_file in sorted(odpowiedzi_dir.glob("*.md")): all_cards.extend(extract_cards(md_file)) - # APPROACH 1: Strict filtering - only cards with answer > 100 chars - filtered_cards = [c for c in all_cards if len(c["back"]) > 100] + # APPROACH 1: Strict filtering - only cards with answer > threshold + filtered_cards = [ + c for c in all_cards if len(c["back"]) > MIN_ANSWER_LENGTH + ] # Remove duplicates seen = set() @@ -120,7 +128,11 @@ def main() -> None: for c in unique: f.write(f"{c['front']}\t{c['back']}\t{c['tags']}\n") - print(f"✅ Approach 1 (Strict Filter): {len(unique)} cards -> {output_file.name}") + logger.info( + "Approach 1 (Strict Filter): %d cards -> %s", + len(unique), + output_file.name, + ) if __name__ == "__main__": diff --git a/python_pkg/praca_magisterska_video/generate_images/anki_approach_2.py b/python_pkg/praca_magisterska_video/generate_images/anki_approach_2.py index 98d459b..ad229ab 100755 --- a/python_pkg/praca_magisterska_video/generate_images/anki_approach_2.py +++ b/python_pkg/praca_magisterska_video/generate_images/anki_approach_2.py @@ -7,11 +7,17 @@ from __future__ import annotations +import logging from pathlib import Path import re +logger = logging.getLogger(__name__) -def clean_text(text) -> str: +MIN_PARA_LENGTH = 30 +MIN_BODY_LENGTH = 50 + + +def clean_text(text: str) -> str: """Clean text.""" if not text: return "" @@ -23,7 +29,7 @@ def clean_text(text) -> str: return text.strip() -def extract_structured_content(body) -> str | None: +def extract_structured_content(body: str) -> str | None: """Better extraction - look for multiple content types.""" parts = [] @@ -54,15 +60,14 @@ def extract_structured_content(body) -> str | None: if p.strip() and not p.startswith("```") and not p.startswith("|") - and len(p.strip()) > 30 + and len(p.strip()) > MIN_PARA_LENGTH ] - for p in paras[:2]: - parts.append(p[:300]) + parts.extend(p[:300] for p in paras[:2]) return "
".join([clean_text(p) for p in parts]) if parts else None -def extract_cards(filepath) -> list[dict[str, str]]: +def extract_cards(filepath: str) -> list[dict[str, str]]: """Extract cards.""" with Path(filepath).open(encoding="utf-8") as f: content = f.read() @@ -99,9 +104,9 @@ def extract_cards(filepath) -> list[dict[str, str]]: content, re.MULTILINE | re.DOTALL, ) - for header, body in sections: - header = header.strip() - if "Przykład" in header or '"' in header or len(body) < 50: + for raw_header, body in sections: + header = raw_header.strip() + if "Przykład" in header or '"' in header or len(body) < MIN_BODY_LENGTH: continue answer = extract_structured_content(body) @@ -143,8 +148,10 @@ def main() -> None: for c in unique: f.write(f"{c['front']}\t{c['back']}\t{c['tags']}\n") - print( - f"✅ Approach 2 (Better Extraction): {len(unique)} cards -> {output_file.name}" + logger.info( + "Approach 2 (Better Extraction): %d cards -> %s", + len(unique), + output_file.name, ) diff --git a/python_pkg/praca_magisterska_video/generate_images/anki_generator.py b/python_pkg/praca_magisterska_video/generate_images/anki_generator.py index a62f6c6..d057477 100755 --- a/python_pkg/praca_magisterska_video/generate_images/anki_generator.py +++ b/python_pkg/praca_magisterska_video/generate_images/anki_generator.py @@ -7,31 +7,41 @@ Usage: Options: --filter Apply strict filtering (answers > 100 chars) --extract Use improved extraction algorithm - --main-only Only generate main exam questions (45 comprehensive cards) + --main-only Only generate main exam questions Combinations: - python anki_generator.py # Basic extraction, no filter - python anki_generator.py --filter # Approach 1: Strict filter only - python anki_generator.py --extract # Approach 2: Better extraction only - python anki_generator.py --main-only # Approach 3: Main questions only - python anki_generator.py --filter --extract # Approach 4: Filter + Better extraction - python anki_generator.py --filter --main-only # Approach 5: Filter + Main only - python anki_generator.py --extract --main-only # Approach 6: Better extraction + Main only - python anki_generator.py --filter --extract --main-only # Approach 7: All three + python anki_generator.py + python anki_generator.py --filter + python anki_generator.py --extract + python anki_generator.py --main-only + python anki_generator.py --filter --extract + python anki_generator.py --filter --main-only + python anki_generator.py --extract --main-only + python anki_generator.py --filter --extract --main-only """ from __future__ import annotations import argparse +import logging from pathlib import Path import re +logger = logging.getLogger(__name__) + +MIN_PARTS_THRESHOLD = 2 +MIN_BODY_LENGTH = 50 +MIN_PARA_LENGTH = 30 +SHORT_THRESHOLD = 50 +MEDIUM_THRESHOLD = 150 +DEFAULT_MIN_ANSWER_LENGTH = 100 + # ============================================================================= # SHARED UTILITIES # ============================================================================= -def clean_text(text) -> str: +def clean_text(text: str) -> str: """Clean and format text for Anki.""" if not text: return "" @@ -43,7 +53,7 @@ def clean_text(text) -> str: return text.strip() -def get_file_metadata(filepath) -> tuple[str, str, str]: +def get_file_metadata(filepath: str) -> tuple[str, str, str]: """Extract question number and subject from filename.""" filename = Path(filepath).name match = re.match(r"(\d+)-(.+)\.md", filename) @@ -58,7 +68,7 @@ def get_file_metadata(filepath) -> tuple[str, str, str]: return num, subject, content -def get_main_question(content) -> str | None: +def get_main_question(content: str) -> str | None: """Extract the main exam question.""" q_match = re.search( r'## Pytanie\s*\n\s*\*\*["\']?(.+?)["\']?\*\*', content, re.DOTALL @@ -73,7 +83,10 @@ def get_main_question(content) -> str | None: # ============================================================================= -def apply_strict_filter(cards, min_length=100) -> list[dict[str, str]]: +def apply_strict_filter( + cards: list[dict[str, str]], + min_length: int = DEFAULT_MIN_ANSWER_LENGTH, +) -> list[dict[str, str]]: """Filter cards to only include those with answers > min_length characters.""" return [c for c in cards if len(c["back"]) > min_length] @@ -83,7 +96,7 @@ def apply_strict_filter(cards, min_length=100) -> list[dict[str, str]]: # ============================================================================= -def extract_structured_content(body) -> str | None: +def extract_structured_content(body: str) -> str | None: """Improved extraction - multiple content types with better formatting.""" parts = [] @@ -101,7 +114,7 @@ def extract_structured_content(body) -> str | None: parts.append(f"• {term}") # 3. Key-value patterns - if len(parts) < 2: + if len(parts) < MIN_PARTS_THRESHOLD: kvs = re.findall(r"\*\*([^*\n]+)\*\*\s*[--:]\s*([^\n*]{10,150})", body) for k, v in kvs[:4]: entry = f"{k.strip()}: {v.strip()}" @@ -116,15 +129,14 @@ def extract_structured_content(body) -> str | None: if p.strip() and not p.startswith("```") and not p.startswith("|") - and len(p.strip()) > 30 + and len(p.strip()) > MIN_PARA_LENGTH ] - for p in paras[:2]: - parts.append(p[:300]) + parts.extend(p[:300] for p in paras[:2]) return "
".join([clean_text(p) for p in parts]) if parts else None -def extract_cards_better(filepath) -> list[dict[str, str]]: +def extract_cards_better(filepath: str) -> list[dict[str, str]]: """Extract cards with improved algorithm.""" num, subject, content = get_file_metadata(filepath) base_tags = f"egzamin pyt{num} {subject}" @@ -153,13 +165,13 @@ def extract_cards_better(filepath) -> list[dict[str, str]]: content, re.MULTILINE | re.DOTALL, ) - for header, body in sections: - header = header.strip() + for raw_header, body in sections: + header = raw_header.strip() if ( "Przykład" in header or '"' in header or "Mnemonic" in header - or len(body) < 50 + or len(body) < MIN_BODY_LENGTH ): continue @@ -176,7 +188,7 @@ def extract_cards_better(filepath) -> list[dict[str, str]]: return cards -def extract_cards_basic(filepath) -> list[dict[str, str]]: +def extract_cards_basic(filepath: str) -> list[dict[str, str]]: """Basic extraction - simpler algorithm.""" num, subject, content = get_file_metadata(filepath) base_tags = f"egzamin pyt{num} {subject}" @@ -212,10 +224,10 @@ def extract_cards_basic(filepath) -> list[dict[str, str]]: content, re.MULTILINE | re.DOTALL, ) - for header, body in sections: - header = header.strip() - body = body.strip() - if len(body) < 50 or "Przykład" in header: + for raw_header, raw_body in sections: + header = raw_header.strip() + body = raw_body.strip() + if len(body) < MIN_BODY_LENGTH or "Przykład" in header: continue paras = [ @@ -241,7 +253,28 @@ def extract_cards_basic(filepath) -> list[dict[str, str]]: # ============================================================================= -def extract_main_only(filepath) -> 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 + ) + if def_match: + return def_match.group(1).strip() + + bullets = re.findall(r"[-•]\s*\*\*([^*]+)\*\*[:\s-]*([^\n]*)", body) + if bullets: + term, desc = bullets[0] + return f"{term}: {desc.strip()}" if desc.strip() else term + + para_match = re.search(r"\n\n([^#\n\-•|`][^\n]{20,150})", body) + if para_match: + return para_match.group(1).strip() + + return None + + +def extract_main_only(filepath: str) -> list[dict[str, str]]: """Extract only the main exam question with comprehensive answer.""" num, subject, content = get_file_metadata(filepath) base_tags = f"egzamin pyt{num} {subject} main" @@ -255,7 +288,9 @@ def extract_main_only(filepath) -> list[dict[str, str]]: # Get main answer section answer_match = re.search( - r"## 📚 Odpowiedź główna\s*\n(.+?)(?=\n## [^�]|\Z)", content, re.DOTALL + r"## 📚 Odpowiedź główna\s*\n(.+?)(?=\n## [^�]|\Z)", + content, + re.DOTALL, ) if answer_match: section = answer_match.group(1) @@ -267,32 +302,16 @@ def extract_main_only(filepath) -> list[dict[str, str]]: re.MULTILINE | re.DOTALL, ) - for header, body in headers[:5]: - header = header.strip() - if "Przykład" in header or "Mnemonic" in header or '"' in header: + for raw_header, body in headers[:5]: + header = raw_header.strip() + if ( + "Przykład" in header + or "Mnemonic" in header + or '"' in header + ): continue - # Get key point from this section - key_point = None - - # Try to get a definition or first bullet - def_match = re.search( - r"Rozpoznawana klasa języków\s*\n\s*\*\*([^*]+)\*\*", body - ) - if def_match: - key_point = def_match.group(1).strip() - - if not key_point: - bullets = re.findall(r"[-•]\s*\*\*([^*]+)\*\*[:\s-]*([^\n]*)", body) - if bullets: - term, desc = bullets[0] - key_point = f"{term}: {desc.strip()}" if desc.strip() else term - - if not key_point: - para_match = re.search(r"\n\n([^#\n\-•|`][^\n]{20,150})", body) - if para_match: - key_point = para_match.group(1).strip() - + key_point = _extract_key_point(body) if key_point: answer_parts.append(f"{header}: {key_point}") @@ -308,9 +327,58 @@ def extract_main_only(filepath) -> list[dict[str, str]]: # ============================================================================= -def generate_anki(use_filter=False, use_better_extract=False, main_only=False) -> Path: +def _collect_cards( + odpowiedzi_dir: Path, + *, + use_better_extract: bool, + main_only: bool, +) -> list[dict[str, str]]: + """Collect cards from all files using the specified approach.""" + all_cards = [] + for md_file in sorted(odpowiedzi_dir.glob("*.md")): + if main_only: + cards = extract_main_only(md_file) + elif use_better_extract: + cards = extract_cards_better(md_file) + else: + cards = extract_cards_basic(md_file) + all_cards.extend(cards) + return all_cards + + +def _log_statistics(unique: list[dict[str, str]], output_file: Path) -> None: + """Log quality statistics for the generated cards.""" + 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 + ) + + logger.info("Generated: %s", output_file.name) + logger.info(" Cards: %d", len(unique)) + logger.info( + " Quality: %d short / %d medium / %d good", + short, + medium, + good, + ) + + +def generate_anki( + *, + use_filter: bool = False, + use_better_extract: bool = False, + 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 = [] @@ -322,30 +390,25 @@ def generate_anki(use_filter=False, use_better_extract=False, main_only=False) - 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 = [] - - for md_file in sorted(odpowiedzi_dir.glob("*.md")): - if main_only: - # Approach 3: Only main questions - cards = extract_main_only(md_file) - elif use_better_extract: - # Approach 2: Better extraction - cards = extract_cards_better(md_file) - else: - # Basic extraction - cards = extract_cards_basic(md_file) - - all_cards.extend(cards) + all_cards = _collect_cards( + odpowiedzi_dir, + use_better_extract=use_better_extract, + main_only=main_only, + ) # Approach 1: Apply filtering if requested if use_filter: - all_cards = apply_strict_filter(all_cards, min_length=100) + all_cards = apply_strict_filter( + all_cards, min_length=DEFAULT_MIN_ANSWER_LENGTH + ) # Remove duplicates - seen = set() + seen: set[str] = set() unique = [] for c in all_cards: key = c["front"][:80] @@ -355,20 +418,14 @@ def generate_anki(use_filter=False, use_better_extract=False, main_only=False) - # Write output with Path(output_file).open("w", encoding="utf-8") as f: - f.write(f"#separator:Tab\n#html:true\n#notetype:Basic\n#deck:{deck_name}\n\n") + f.write( + "#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") - # Statistics - lengths = [len(c["back"]) for c in unique] - short = sum(1 for l in lengths if l < 50) - medium = sum(1 for l in lengths if 50 <= l < 150) - good = sum(1 for l in lengths if l >= 150) - - print(f"✅ Generated: {output_file.name}") - print(f" Cards: {len(unique)}") - print(f" Quality: {short} short / {medium} medium / {good} good") - print() + _log_statistics(unique, output_file) return output_file @@ -397,9 +454,9 @@ def main() -> None: if args.all_combinations: # Generate all 7 combinations - print("=" * 60) - print("Generating all 7 combinations...") - print("=" * 60 + "\n") + logger.info("=" * 60) + logger.info("Generating all 7 combinations...") + logger.info("=" * 60) combinations = [ (True, False, False), # 1: Filter only @@ -411,9 +468,22 @@ def main() -> None: (True, True, True), # 7: All three ] - for i, (f, e, m) in enumerate(combinations, 1): - print(f"--- Combination {i} (filter={f}, extract={e}, main={m}) ---") - generate_anki(use_filter=f, use_better_extract=e, main_only=m) + for i, (f_flag, e_flag, m_flag) in enumerate( + combinations, 1 + ): + logger.info( + "--- Combination %d (filter=%s, extract=%s," + " main=%s) ---", + i, + f_flag, + e_flag, + m_flag, + ) + generate_anki( + use_filter=f_flag, + use_better_extract=e_flag, + main_only=m_flag, + ) else: generate_anki( use_filter=args.filter, diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_agent_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_agent_diagrams.py index 0e9acdf..14c60a2 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_agent_diagrams.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_agent_diagrams.py @@ -12,15 +12,24 @@ All: A4-compatible, B&W, 300 DPI, laser-printer-friendly. from __future__ import annotations +from dataclasses import dataclass +import logging +from pathlib import Path +from typing import TYPE_CHECKING + import matplotlib as mpl mpl.use("Agg") -from pathlib import Path import matplotlib.patches as mpatches from matplotlib.patches import FancyBboxPatch import matplotlib.pyplot as plt +if TYPE_CHECKING: + from matplotlib.axes import Axes + +logger = logging.getLogger(__name__) + DPI = 300 BG = "white" LN = "black" @@ -36,85 +45,160 @@ GRAY4 = "#F5F5F5" GRAY5 = "#C0C0C0" +@dataclass(frozen=True) +class BoxStyle: + """Optional styling for boxes.""" + + fill: str = "white" + lw: float = 1.2 + fontsize: float = FS + fontweight: str = "normal" + ha: str = "center" + va: str = "center" + rounded: bool = True + + +@dataclass(frozen=True) +class ArrowCfg: + """Optional config for arrows.""" + + lw: float = 1.2 + style: str = "->" + color: str = LN + label: str = "" + label_offset: float = 0.12 + + +@dataclass(frozen=True) +class DashedArrowCfg: + """Optional config for dashed arrows.""" + + lw: float = 1.0 + color: str = LN + label: str = "" + label_offset: float = 0.12 + + def draw_box( - ax, - x, - y, - w, - h, - text, - fill="white", - lw=1.2, - fontsize=FS, - fontweight="normal", - ha="center", - va="center", - rounded=True, + ax: Axes, + pos: tuple[float, float], + size: tuple[float, float], + text: str, + style: BoxStyle | None = None, ) -> None: """Draw box.""" - if rounded: + s = style or BoxStyle() + x, y = pos + w, h = size + if s.rounded: rect = FancyBboxPatch( - (x, y), w, h, boxstyle="round,pad=0.05", lw=lw, edgecolor=LN, facecolor=fill + (x, y), + w, + h, + boxstyle="round,pad=0.05", + lw=s.lw, + edgecolor=LN, + facecolor=s.fill, ) else: - rect = mpatches.Rectangle((x, y), w, h, lw=lw, edgecolor=LN, facecolor=fill) + rect = mpatches.Rectangle( + (x, y), + w, + h, + lw=s.lw, + edgecolor=LN, + facecolor=s.fill, + ) ax.add_patch(rect) ax.text( x + w / 2, y + h / 2, text, - ha=ha, - va=va, - fontsize=fontsize, - fontweight=fontweight, + ha=s.ha, + va=s.va, + fontsize=s.fontsize, + fontweight=s.fontweight, wrap=True, ) def draw_arrow( - ax, x1, y1, x2, y2, lw=1.2, style="->", color=LN, label="", label_offset=0.12 + ax: Axes, + start: tuple[float, float], + end: tuple[float, float], + cfg: ArrowCfg | None = None, ) -> None: """Draw arrow.""" + c = cfg or ArrowCfg() ax.annotate( "", - xy=(x2, y2), - xytext=(x1, y1), - arrowprops={"arrowstyle": style, "color": color, "lw": lw}, + xy=end, + xytext=start, + arrowprops={ + "arrowstyle": c.style, + "color": c.color, + "lw": c.lw, + }, ) - if label: - mx, my = (x1 + x2) / 2, (y1 + y2) / 2 + label_offset - ax.text(mx, my, label, ha="center", va="bottom", fontsize=6.5, color=color) + if c.label: + mx = (start[0] + end[0]) / 2 + my = (start[1] + end[1]) / 2 + c.label_offset + ax.text( + mx, + my, + c.label, + ha="center", + va="bottom", + fontsize=6.5, + color=c.color, + ) def draw_dashed_arrow( - ax, x1, y1, x2, y2, lw=1.0, color=LN, label="", label_offset=0.12 + ax: Axes, + start: tuple[float, float], + end: tuple[float, float], + cfg: DashedArrowCfg | None = None, ) -> None: """Draw dashed arrow.""" + c = cfg or DashedArrowCfg() ax.annotate( "", - xy=(x2, y2), - xytext=(x1, y1), + xy=end, + xytext=start, arrowprops={ "arrowstyle": "->", - "color": color, - "lw": lw, + "color": c.color, + "lw": c.lw, "linestyle": "dashed", }, ) - if label: - mx, my = (x1 + x2) / 2, (y1 + y2) / 2 + label_offset - ax.text(mx, my, label, ha="center", va="bottom", fontsize=6.5, color=color) + if c.label: + mx = (start[0] + end[0]) / 2 + my = (start[1] + end[1]) / 2 + c.label_offset + ax.text( + mx, + my, + c.label, + ha="center", + va="bottom", + fontsize=6.5, + color=c.color, + ) -# ─── DIAGRAM 1: See-Think-Act Cycle ────────────────────────────── +# --- 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) + 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 → Deliberacja → Akcja", + "Cykl agenta upostaciowionego:" + " Percepcja \u2192 Deliberacja \u2192 Akcja", fontsize=FS_TITLE, fontweight="bold", pad=10, @@ -135,7 +219,8 @@ def draw_see_think_act() -> None: ax.text( 3.5, 0.7, - "ŚRODOWISKO FIZYCZNE\n(przeszkody, obiekty, ludzie)", + "\u015aRODOWISKO FIZYCZNE\n" + "(przeszkody, obiekty, ludzie)", ha="center", va="center", fontsize=FS, @@ -167,18 +252,17 @@ def draw_see_think_act() -> None: 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, + (0.8, by), + (bw, bh), "SEE\n(Percepcja)", - fill=GRAY2, - fontsize=8, - fontweight="bold", + bold_fs8, ) ax.text( 1.5, @@ -193,14 +277,12 @@ def draw_see_think_act() -> None: # THINK draw_box( ax, - 2.8, - by, - bw, - bh, + (2.8, by), + (bw, bh), "THINK\n(Deliberacja)", - fill=GRAY3, - fontsize=8, - fontweight="bold", + BoxStyle( + fill=GRAY3, fontsize=8, fontweight="bold" + ), ) ax.text( 3.5, @@ -214,7 +296,11 @@ def draw_see_think_act() -> None: # ACT draw_box( - ax, 4.8, by, bw, bh, "ACT\n(Akcja)", fill=GRAY2, fontsize=8, fontweight="bold" + ax, + (4.8, by), + (bw, bh), + "ACT\n(Akcja)", + bold_fs8, ) ax.text( 5.5, @@ -228,17 +314,43 @@ def draw_see_think_act() -> None: # Arrows between phases draw_arrow( - ax, 0.8 + bw, by + bh / 2, 2.8, by + bh / 2, lw=1.5, label="dane sensoryczne" + 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, lw=1.5, label="komendy sterujące" + 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, lw=1.3, label="odczyt", label_offset=0.08) - draw_arrow(ax, 5.5, by, 5.5, 1.2, lw=1.3, label="działanie", label_offset=0.08) + 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 (from ACT back to environment back to SEE) + # Feedback loop arrow ax.annotate( "", xy=(1.5, 1.15), @@ -254,7 +366,8 @@ def draw_see_think_act() -> None: ax.text( 3.5, 0.35, - "← sprzężenie zwrotne (efekt akcji zmienia środowisko) →", + "\u2190 sprz\u0119\u017cenie zwrotne" + " (efekt akcji zmienia \u015brodowisko) \u2192", ha="center", va="center", fontsize=6, @@ -262,21 +375,28 @@ def draw_see_think_act() -> None: ) fig.tight_layout() - path = str(Path(OUTPUT_DIR) / "agent_see_think_act.png") - fig.savefig(path, dpi=DPI, bbox_inches="tight", facecolor=BG) + path = str( + Path(OUTPUT_DIR) / "agent_see_think_act.png" + ) + fig.savefig( + path, dpi=DPI, bbox_inches="tight", facecolor=BG + ) plt.close(fig) - print(f" ✓ {path}") + logger.info(" \u2713 %s", path) -# ─── DIAGRAM 2: 3T Architecture ───────────────────────────────── +# --- DIAGRAM 2: 3T Architecture --- def draw_3t_architecture() -> None: """Draw 3t architecture.""" - fig, ax = plt.subplots(1, 1, figsize=(7, 5.5), facecolor=BG) + 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)", + "Architektura 3T sterownika robota" + " (3-Layer Architecture)", fontsize=FS_TITLE, fontweight="bold", pad=10, @@ -288,46 +408,52 @@ def draw_3t_architecture() -> None: "name": "WARSTWA 3: PLANNER\n(Deliberacja)", "time": "sekundy \u2013 minuty", "fill": GRAY1, - "example": 'Cel: "Jed\u017a do kuchni po kubek"\nPlanowanie trasy A \u2192 B \u2192 C', - "tech": "Planowanie symboliczne\nA*, graf zada\u0144", + "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\nOtw\u00f3rz \u2192 Jed\u017a do blatu \u2192 Chwy\u0107", - "tech": "FSM, Behavior Trees\nkoordynacja zachowa\u0144", + "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\nUnikaj kolizji (emergency stop)", - "tech": "PID, regulacja\nbezpo\u015brednie I/O", + "example": ( + "PID: utrzymaj pr\u0119dko\u015b\u0107" + " 0.5 m/s\n" + "Unikaj kolizji (emergency stop)" + ), }, ] bw = 4.0 bh = 0.85 - for _i, layer in enumerate(layers): + for layer in layers: y = layer["y"] - # Main layer box draw_box( ax, - 0.3, - y, - bw, - bh, + (0.3, y), + (bw, bh), layer["name"], - fill=layer["fill"], - fontsize=8, - fontweight="bold", + BoxStyle( + fill=layer["fill"], + fontsize=8, + fontweight="bold", + ), ) - # Time label (left) ax.text( 0.15, y + bh / 2, @@ -345,33 +471,38 @@ def draw_3t_architecture() -> None: }, ) - # Example (right side) - draw_box(ax, 4.5, y, 2.3, bh, layer["example"], fill="white", fontsize=6.5) + draw_box( + ax, + (4.5, y), + (2.3, bh), + layer["example"], + BoxStyle(fontsize=6.5), + ) - # Technology used - # ax.text(4.5 + 1.15, y - 0.12, layer["tech"], ha='center', va='top', - # fontsize=5.5, fontstyle='italic', color='#555555') - - # Arrows between layers (downward = commands, upward = status) + # Arrows between layers for i in range(len(layers) - 1): y_top = layers[i]["y"] y_bot = layers[i + 1]["y"] + 0.85 - # Command arrow (down) - draw_arrow( - ax, 1.8, y_top, 1.8, y_bot, lw=1.3, label="polecenia ↓", label_offset=0.02 - ) - # Status arrow (up) draw_arrow( ax, - 2.8, - y_bot, - 2.8, - y_top, - lw=1.0, - style="->", - color="#666666", - label="↑ status", - label_offset=0.02, + (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 @@ -389,22 +520,27 @@ def draw_3t_architecture() -> None: ax.text( 0.3 + bw / 2, 0.6, - "SPRZĘT: silniki, czujniki, efektory", + "SPRZ\u0118T: silniki, czujniki, efektory", ha="center", va="center", fontsize=7, fontstyle="italic", ) - # Arrow from controller to hardware - draw_arrow(ax, 2.3, 1.2, 2.3, 0.9, lw=1.3) + 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}, + arrowprops={ + "arrowstyle": "<->", + "color": "#888888", + "lw": 1.0, + }, ) ax.text( 6.95, @@ -418,33 +554,43 @@ def draw_3t_architecture() -> None: ) fig.tight_layout() - path = str(Path(OUTPUT_DIR) / "agent_3t_architecture.png") - fig.savefig(path, dpi=DPI, bbox_inches="tight", facecolor=BG) + path = str( + Path(OUTPUT_DIR) / "agent_3t_architecture.png" + ) + fig.savefig( + path, dpi=DPI, bbox_inches="tight", facecolor=BG + ) plt.close(fig) - print(f" ✓ {path}") + logger.info(" \u2713 %s", path) -# ─── DIAGRAM 3: Behavior Tree Example ──────────────────────────── +# --- 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) + 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ący obiekt (pick-and-place)", + "Behavior Tree: robot przenosz\u0105cy" + " obiekt (pick-and-place)", fontsize=FS_TITLE, fontweight="bold", pad=10, ) - # Node positions: (x, y, text, shape_type) - # shape_type: 'seq' = sequence (→), 'sel' = selector (?), 'act' = action, 'cond' = condition - - def draw_bt_node(ax, x, y, text, ntype="act", w=1.0, h=0.45) -> tuple[float, float]: + 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": - # Sequence = box with → rect = FancyBboxPatch( (x - w / 2, y - h / 2), w, @@ -458,14 +604,13 @@ def draw_behavior_tree() -> None: ax.text( x, y, - f"→ {text}", + f"\u2192 {text}", ha="center", va="center", fontsize=7, fontweight="bold", ) elif ntype == "sel": - # Selector = box with ? rect = FancyBboxPatch( (x - w / 2, y - h / 2), w, @@ -486,7 +631,6 @@ def draw_behavior_tree() -> None: fontweight="bold", ) elif ntype == "cond": - # Condition = diamond-ish / oval with dashed border rect = FancyBboxPatch( (x - w / 2, y - h / 2), w, @@ -499,7 +643,13 @@ def draw_behavior_tree() -> None: ) ax.add_patch(rect) ax.text( - x, y, text, ha="center", va="center", fontsize=6.5, fontstyle="italic" + x, + y, + text, + ha="center", + va="center", + fontsize=6.5, + fontstyle="italic", ) else: # action rect = FancyBboxPatch( @@ -512,53 +662,121 @@ def draw_behavior_tree() -> None: facecolor=GRAY1, ) ax.add_patch(rect) - ax.text(x, y, text, ha="center", va="center", fontsize=6.5) + ax.text( + x, + y, + text, + ha="center", + va="center", + fontsize=6.5, + ) return x, y - # Root: Sequence "Przenieś obiekt" - root = draw_bt_node(ax, 3.75, 3.8, "Przenieś obiekt", "seq", w=1.6) + # 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(ax, 1.2, 2.8, "Znajdź obiekt", "sel", w=1.3) - nav = draw_bt_node(ax, 3.75, 2.8, "Jedź do obiektu", "act", w=1.3) - pick = draw_bt_node(ax, 6.3, 2.8, "Chwyć i dostarcz", "seq", w=1.4) + 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 - draw_arrow(ax, root[0], root[1] - 0.225, find[0], find[1] + 0.225, lw=1.0) - draw_arrow(ax, root[0], root[1] - 0.225, nav[0], nav[1] + 0.225, lw=1.0) - draw_arrow(ax, root[0], root[1] - 0.225, pick[0], pick[1] + 0.225, lw=1.0) + 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 "Znajdź obiekt" (selector) - vis = draw_bt_node(ax, 0.55, 1.7, "Widzę\nobiekt?", "cond", w=0.85, h=0.5) - scan = draw_bt_node(ax, 1.85, 1.7, "Skanuj\notoczenie", "act", w=0.85, h=0.5) - draw_arrow(ax, find[0], find[1] - 0.225, vis[0], vis[1] + 0.25, lw=0.8) - draw_arrow(ax, find[0], find[1] - 0.225, scan[0], scan[1] + 0.25, lw=0.8) + # 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 "Chwyć i dostarcz" (sequence) - grasp = draw_bt_node(ax, 5.4, 1.7, "Chwyć\nobject", "act", w=0.85, h=0.5) - deliver = draw_bt_node(ax, 6.5, 1.7, "Jedź do\ncelu", "act", w=0.85, h=0.5) - release = draw_bt_node(ax, 7.2, 1.7, "Puść", "act", w=0.55, h=0.5) - draw_arrow(ax, pick[0], pick[1] - 0.225, grasp[0], grasp[1] + 0.25, lw=0.8) - draw_arrow(ax, pick[0], pick[1] - 0.225, deliver[0], deliver[1] + 0.25, lw=0.8) - draw_arrow(ax, pick[0], pick[1] - 0.225, release[0], release[1] + 0.25, lw=0.8) + # 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(ax, 0.8, leg_y, "→ Sequence", "seq", w=1.1, h=0.35) - draw_bt_node(ax, 2.3, leg_y, "? Selector", "sel", w=1.0, h=0.35) - draw_bt_node(ax, 3.6, leg_y, "Akcja", "act", w=0.8, h=0.35) - draw_bt_node(ax, 4.8, leg_y, "Warunek", "cond", w=0.8, h=0.35) + 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" + 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 (→) = wszystkie po kolei. " - "Selector (?) = pierwszy sukces.", + "Wykonanie: od lewej do prawej." + " Sequence (\u2192) = wszystkie po kolei." + " Selector (?) = pierwszy sukces.", ha="center", va="center", fontsize=6, @@ -567,21 +785,28 @@ def draw_behavior_tree() -> None: ) fig.tight_layout() - path = str(Path(OUTPUT_DIR) / "agent_behavior_tree.png") - fig.savefig(path, dpi=DPI, bbox_inches="tight", facecolor=BG) + path = str( + Path(OUTPUT_DIR) / "agent_behavior_tree.png" + ) + fig.savefig( + path, dpi=DPI, bbox_inches="tight", facecolor=BG + ) plt.close(fig) - print(f" ✓ {path}") + logger.info(" \u2713 %s", path) -# ─── DIAGRAM 4: BDI Model ──────────────────────────────────────── +# --- DIAGRAM 4: BDI Model --- def draw_bdi_model() -> None: """Draw bdi model.""" - fig, ax = plt.subplots(1, 1, figsize=(7, 4), facecolor=BG) + 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)", + "Model BDI agenta" + " (Beliefs-Desires-Intentions)", fontsize=FS_TITLE, fontweight="bold", pad=10, @@ -589,9 +814,12 @@ def draw_bdi_model() -> None: 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, "", fill=GRAY1, fontsize=8, fontweight="bold") + draw_box(ax, (0.3, 1.3), (bw, bh), "", bold8) ax.text( 0.3 + bw / 2, 1.3 + bh - 0.15, @@ -604,14 +832,26 @@ def draw_bdi_model() -> None: 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%", + "(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, "", fill=GRAY2, fontsize=8, fontweight="bold") + 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, @@ -624,14 +864,26 @@ def draw_bdi_model() -> None: ax.text( 2.7 + bw / 2, 1.3 + bh / 2 - 0.1, - "(cele agenta)\n\n• dostarczyć paczkę\n do pokoju 5\n• naładować baterię\n• unikać kolizji", + "(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, "", fill=GRAY3, fontsize=8, fontweight="bold") + 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, @@ -644,7 +896,11 @@ def draw_bdi_model() -> None: ax.text( 5.1 + bw / 2, 1.3 + bh / 2 - 0.1, - "(aktualny plan)\n\n→ jedź do drzwi\n bocznych\n→ otwórz drzwi\n→ wjedź do pokoju 5", + "(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, @@ -653,26 +909,26 @@ def draw_bdi_model() -> None: # Arrows draw_arrow( ax, - 0.3 + bw, - 1.3 + bh / 2 + 0.15, - 2.7, - 1.3 + bh / 2 + 0.15, - lw=1.3, - label="informuje", - label_offset=0.08, + (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, - lw=1.3, - label="filtruje → wybiera", - label_offset=0.08, + (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 from intentions back to beliefs (update) + # Feedback: intentions back to beliefs ax.annotate( "", xy=(0.3 + bw / 2, 1.3), @@ -699,18 +955,18 @@ def draw_bdi_model() -> None: # Sensor input arrow draw_arrow( ax, - 0.3 + bw / 2, - 3.5, - 0.3 + bw / 2, - 1.3 + bh, - lw=1.3, - label="percepcja (sensory)", - label_offset=0.05, + (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, - "ŚRODOWISKO", + "\u015aRODOWISKO", ha="center", va="bottom", fontsize=7, @@ -726,13 +982,13 @@ def draw_bdi_model() -> None: # Action output arrow draw_arrow( ax, - 5.1 + bw / 2, - 1.3 + bh, - 5.1 + bw / 2, - 3.5, - lw=1.3, - label="akcja (efektory)", - label_offset=0.05, + (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, @@ -752,16 +1008,18 @@ def draw_bdi_model() -> None: fig.tight_layout() path = str(Path(OUTPUT_DIR) / "agent_bdi_model.png") - fig.savefig(path, dpi=DPI, bbox_inches="tight", facecolor=BG) + fig.savefig( + path, dpi=DPI, bbox_inches="tight", facecolor=BG + ) plt.close(fig) - print(f" ✓ {path}") + logger.info(" \u2713 %s", path) -# ─── MAIN ──────────────────────────────────────────────────────── +# --- MAIN --- if __name__ == "__main__": - print("Generating PYTANIE 15 diagrams...") + logger.info("Generating PYTANIE 15 diagrams...") draw_see_think_act() draw_3t_architecture() draw_behavior_tree() draw_bdi_model() - print("Done! All diagrams saved to", OUTPUT_DIR) + logger.info("Done! All diagrams saved to %s", OUTPUT_DIR) diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_anki.py b/python_pkg/praca_magisterska_video/generate_images/generate_anki.py index 3fcd66b..1cfd7c7 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_anki.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_anki.py @@ -6,18 +6,27 @@ Creates a tab-separated file compatible with Anki import. from __future__ import annotations +import logging from pathlib import Path import re +logger = logging.getLogger(__name__) -def extract_question_and_answer(filepath) -> list[dict[str, str]]: - """Extract main question and key answer points from a markdown file.""" +MIN_BODY_LENGTH = 50 +MIN_DEFINITION_LENGTH = 20 +MAX_DEFINITION_LENGTH = 200 +MIN_BULLET_COUNT = 5 +MIN_SUBSECTION_LENGTH = 5 +MIN_FORMULA_LENGTH = 20 + + +def _get_metadata( + filepath: str, +) -> tuple[str, str, str, str, str]: + """Extract metadata from file.""" with Path(filepath).open(encoding="utf-8") as f: content = f.read() - cards = [] - - # Extract file number for tagging filename = Path(filepath).name match = re.match(r"(\d+)-(.+)\.md", filename) if match: @@ -27,13 +36,13 @@ def extract_question_and_answer(filepath) -> list[dict[str, str]]: num = "00" topic = "unknown" - # Extract main title (usually contains the question) title_match = re.search(r"^# (.+)$", content, re.MULTILINE) title = title_match.group(1) if title_match else "Unknown" - # Extract the main question from ## Pytanie section question_match = re.search( - r'## Pytanie\s*\n\s*\*\*["\']?(.+?)["\']?\*\*', content, re.DOTALL + r'## Pytanie\s*\n\s*\*\*["\']?(.+?)["\']?\*\*', + content, + re.DOTALL, ) if question_match: main_question = question_match.group(1).strip() @@ -41,124 +50,207 @@ def extract_question_and_answer(filepath) -> list[dict[str, str]]: else: main_question = title - # Extract subject/przedmiot - subject_match = re.search(r"Przedmiot:\s*(\w+)", content) - subject = subject_match.group(1) if subject_match else "Ogólne" + return num, topic, title, main_question, content - # Create main question card - extract key sections for answer - answer_parts = [] - # Look for main answer section +def _extract_main_card( + content: str, + main_question: str, + subject: str, + num: str, + topic: str, +) -> list[dict[str, str]]: + """Extract the main question card.""" + answer_parts: list[str] = [] + main_answer = re.search( - r"## 📚 Odpowiedź główna\s*\n(.+?)(?=\n## |\n---\s*\n## |\Z)", + r"## 📚 Odpowiedź główna\s*\n(.+?)" + r"(?=\n## |\n---\s*\n## |\Z)", content, re.DOTALL, ) if main_answer: answer_text = main_answer.group(1) - # Extract key points, definitions, headers headers = re.findall(r"### (.+)", answer_text) - for h in headers[:5]: # Limit to first 5 headers - answer_parts.append(f"• {h}") + answer_parts.extend(f"• {h}" for h in headers[:5]) - # Also extract key definitions if present - 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) > 20 and len(definition) < 200: - answer_parts.append(f"• {term}: {definition.strip()}") + if ( + len(definition) > MIN_DEFINITION_LENGTH + and len(definition) < MAX_DEFINITION_LENGTH + ): + answer_parts.append( + f"• {term}: {definition.strip()}" + ) - # If we found answer parts, create main card - if answer_parts: - answer_html = "
".join(answer_parts[:8]) # Limit answer length - cards.append( - { - "question": main_question, - "answer": answer_html, - "tags": f"egzamin_magisterski pytanie_{num} {subject} {topic}", - } + if not answer_parts: + return [] + + answer_html = "
".join(answer_parts[:8]) + return [ + { + "question": main_question, + "answer": answer_html, + "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 + ) + if bullets: + return "
".join( + f"• {b[0]}: {b[1].strip()}" if b[1] else f"• {b[0]}" + for b in bullets[:MIN_BULLET_COUNT] ) - # Extract sub-questions and key concepts as additional cards - # Look for ### headers with explanations + paragraphs = [ + p.strip() + for p in body_clean.split("\n\n") + if p.strip() + and not p.startswith("```") + and not p.startswith("|") + ] + if paragraphs: + first_para = paragraphs[0] + first_para = re.sub(r"\*\*(.+?)\*\*", r"\1", first_para) + first_para = re.sub(r"\*(.+?)\*", r"\1", first_para) + return first_para[:400] + + return None + + +def _extract_sub_cards( + content: str, + title: str, + subject: str, + num: str, + topic: str, +) -> list[dict[str, str]]: + """Extract sub-concept cards.""" + cards: list[dict[str, str]] = [] subsections = re.findall( - r"### (\d+\.\s+)?(.+?)\n\n(.+?)(?=\n### |\n## |\n---|\Z)", content, re.DOTALL + r"### (\d+\.\s+)?(.+?)\n\n(.+?)" + r"(?=\n### |\n## |\n---|\Z)", + content, + re.DOTALL, ) for _, header, body in subsections: - if len(header) < 5 or header.startswith("Przykład"): - continue - - # Extract first substantive paragraph or key points - body_clean = body.strip() - - # Skip very short or code-only sections - if len(body_clean) < 50: - continue - - # Extract bullet points or first paragraph - bullets = re.findall(r"[-•]\s*\*\*(.+?)\*\*[:\s]*([^\n]+)?", body_clean) - if bullets: - answer_text = "
".join( - [ - f"• {b[0]}: {b[1].strip()}" if b[1] else f"• {b[0]}" - for b in bullets[:5] - ] - ) - else: - # Get first meaningful paragraph - paragraphs = [ - p.strip() - for p in body_clean.split("\n\n") - if p.strip() and not p.startswith("```") and not p.startswith("|") - ] - if paragraphs: - first_para = paragraphs[0] - # Clean markdown - first_para = re.sub(r"\*\*(.+?)\*\*", r"\1", first_para) - first_para = re.sub(r"\*(.+?)\*", r"\1", first_para) - answer_text = first_para[:400] - else: - continue - - # Create sub-concept card - sub_question = f"Co to jest {header}?" if not header.endswith("?") else header if ( - "Charakterystyka" in header - or "Definicja" in header - or "Właściwości" in header + len(header) < MIN_SUBSECTION_LENGTH + or header.startswith("Przykład") ): - # These are answer-type headers, reframe - parent_topic = title.replace("Pytanie", "").strip(": 0123456789") - sub_question = f"{header} - {parent_topic}" + continue + + body_clean = body.strip() + if len(body_clean) < MIN_BODY_LENGTH: + continue + + answer_text = _extract_subsection_answer(body_clean) + if not answer_text: + continue + + 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" + ) + sub_question = f"{header} - {parent}" cards.append( { "question": sub_question, "answer": answer_text, - "tags": f"egzamin_magisterski pytanie_{num} {subject} {topic} szczegoly", + "tags": ( + f"egzamin_magisterski pytanie_{num}" + f" {subject} {topic} szczegoly" + ), } ) - # Extract key formulas/definitions as separate cards + return cards + + +def _extract_formula_cards( + content: str, + subject: str, + num: str, +) -> list[dict[str, str]]: + """Extract formula/definition cards.""" + cards: list[dict[str, str]] = [] formulas = re.findall( - r"\*\*([A-Za-z\s]+(?:formuła|wzór|twierdzenie|definicja|lemat))\*\*[:\s]*\n?(.+?)(?=\n\n|\n\*\*|\Z)", + r"\*\*([A-Za-z\s]+" + r"(?:formuła|wzór|twierdzenie|definicja|lemat))" + r"\*\*[:\s]*\n?(.+?)(?=\n\n|\n\*\*|\Z)", content, re.IGNORECASE | re.DOTALL, ) for formula_name, formula_content in formulas: - if len(formula_content) > 20: + if len(formula_content) > MIN_FORMULA_LENGTH: cards.append( { "question": f"Podaj {formula_name.strip()}", "answer": formula_content.strip()[:300], - "tags": f"egzamin_magisterski pytanie_{num} {subject} formuly", + "tags": ( + f"egzamin_magisterski pytanie_{num}" + f" {subject} formuly" + ), } ) return cards -def clean_for_anki(text) -> str: +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 + ) + + subject_match = re.search(r"Przedmiot:\s*(\w+)", content) + 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) + ) + + return cards + + +def clean_for_anki(text: str) -> str: """Clean text for Anki import - escape special characters.""" # Replace tabs with spaces text = text.replace("\t", " ") @@ -187,13 +279,13 @@ def main() -> None: # Process each file for md_file in sorted(odpowiedzi_dir.glob("*.md")): - print(f"Processing: {md_file.name}") + logger.info("Processing: %s", md_file.name) try: cards = extract_question_and_answer(md_file) all_cards.extend(cards) - print(f" -> Extracted {len(cards)} cards") - except Exception as e: - print(f" -> Error: {e}") + logger.info(" -> Extracted %d cards", len(cards)) + except (ValueError, OSError) as e: + logger.info(" -> Error: %s", e) # Write Anki file with headers with Path(output_file).open("w", encoding="utf-8") as f: @@ -211,13 +303,13 @@ def main() -> None: tags = card["tags"] f.write(f"{front}\t{back}\t{tags}\n") - print(f"\n✅ Created {len(all_cards)} flashcards") - print(f"📁 Output: {output_file}") - print("\nTo import into Anki:") - print("1. Open Anki → File → Import") - print("2. Select the .txt file") - print("3. Verify 'Allow HTML' is checked") - print("4. Click Import") + logger.info("Created %d flashcards", len(all_cards)) + logger.info("Output: %s", output_file) + logger.info("To import into Anki:") + logger.info("1. Open Anki -> File -> Import") + logger.info("2. Select the .txt file") + logger.info("3. Verify 'Allow HTML' is checked") + logger.info("4. Click Import") if __name__ == "__main__": diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_anki_final.py b/python_pkg/praca_magisterska_video/generate_images/generate_anki_final.py index 7435559..9d36391 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_anki_final.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_anki_final.py @@ -6,11 +6,22 @@ Creates tab-separated file for Anki import with proper HTML formatting. from __future__ import annotations +import logging from pathlib import Path import re +logger = logging.getLogger(__name__) -def clean_text(text) -> str: +MIN_HEADER_LENGTH = 3 +MIN_MATCH_LENGTH = 10 +MIN_BODY_LENGTH = 50 +MIN_QA_LENGTH = 30 +MAX_CONTENT_LENGTH = 300 +MAX_ANSWER_LENGTH = 400 +MAX_COMPARISON_ITEMS = 6 + + +def clean_text(text: str) -> str: """Clean and format text for Anki.""" if not text: return "" @@ -28,7 +39,7 @@ def clean_text(text) -> str: return text.strip() -def format_list(items, numbered=False) -> str: +def format_list(items: list[str], *, numbered: bool = False) -> str: """Format a list of items as HTML.""" if not items: return "" @@ -43,119 +54,148 @@ def format_list(items, numbered=False) -> str: return html -def extract_from_file(filepath) -> list[dict[str, str]]: - """Extract flashcard data from a markdown file.""" +def _get_file_metadata( + filepath: str, +) -> tuple[str, str, str]: + """Extract metadata from file.""" with Path(filepath).open(encoding="utf-8") as f: content = f.read() - cards = [] - - # Get file metadata filename = Path(filepath).name match = re.match(r"(\d+)-(.+)\.md", filename) num = match.group(1) if match else "00" - match.group(2).replace("-", "_") if match else "unknown" - # Extract subject subj_match = re.search(r"Przedmiot:\s*(\w+)", content) subject = subj_match.group(1) if subj_match else "Ogólne" - # Base tags - base_tags = f"egzamin_magisterski pyt{num} {subject}" + return num, subject, content - # ===================================================== - # CARD TYPE 1: Main Exam Question - # ===================================================== + +def _extract_main_question_card( + content: str, base_tags: str, +) -> list[dict[str, str]]: + """Extract the main exam question card.""" q_match = re.search( - r'## Pytanie\s*\n\s*\*\*["\']?(.+?)["\']?\*\*', content, re.DOTALL + r'## Pytanie\s*\n\s*\*\*["\']?(.+?)["\']?\*\*', + content, + re.DOTALL, ) - if q_match: - main_q = re.sub(r"\s+", " ", q_match.group(1).strip()) + if not q_match: + return [] - # Extract key topics from main answer - answer_match = re.search( - r"## 📚 Odpowiedź główna\s*\n(.+?)(?=\n## [�🎯]|\n---\s*\n## |\Z)", - content, - re.DOTALL, + 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)", + content, + re.DOTALL, + ) + if not answer_match: + return [] + + answer_section = answer_match.group(1) + headers = re.findall( + r"^### (?:\d+\.\s*)?(.+)$", + answer_section, + re.MULTILINE, + ) + headers = [ + h.strip() + for h in headers + if len(h.strip()) > MIN_HEADER_LENGTH + ][:6] + + if not headers: + return [] + + answer_html = ( + "Kluczowe zagadnienia:" + format_list(headers) + ) + return [ + { + "front": clean_text(main_q), + "back": answer_html, + "tags": f"{base_tags} pytanie_glowne", + } + ] + + +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()}?" ) - if answer_match: - answer_section = answer_match.group(1) - # Get main headers - headers = re.findall( - r"^### (?:\d+\.\s*)?(.+)$", answer_section, re.MULTILINE + if "Charakterystyka" in header: + stripped = header.replace("Charakterystyka", "").strip() + return f"Scharakteryzuj: {stripped}" + if header.endswith("?"): + return header + return f"Omów: {header}" + + +def _extract_body_parts(body: str) -> list[str]: + """Extract structured answer parts from a section body.""" + answer_parts: list[str] = [] + + subheaders = re.findall(r"^#### (.+)$", body, re.MULTILINE) + if subheaders: + answer_parts.extend(subheaders[:4]) + + bullets = re.findall( + r"[-•]\s*\*\*([^*]+)\*\*[:\s-]*([^\n]+)?", body + ) + for term, desc in bullets[:5]: + if desc: + answer_parts.append( + f"{term}: {desc.strip()}" ) - headers = [h.strip() for h in headers if len(h.strip()) > 3][:6] + else: + answer_parts.append(f"{term}") - if headers: - answer_html = "Kluczowe zagadnienia:" + format_list(headers) - cards.append( - { - "front": clean_text(main_q), - "back": answer_html, - "tags": f"{base_tags} pytanie_glowne", - } - ) + if not answer_parts: + paras = [ + p.strip() + for p in body.split("\n\n") + if p.strip() + and not p.strip().startswith("```") + and not p.strip().startswith("|") + ] + if paras: + first = paras[0] + if len(first) > MAX_CONTENT_LENGTH: + first = first[:MAX_CONTENT_LENGTH] + "..." + answer_parts.append(first) - # ===================================================== - # CARD TYPE 2: Subsection Cards (detailed concepts) - # ===================================================== - # Find all ### sections + return answer_parts + + +def _extract_subsection_cards( + content: str, base_tags: str, +) -> list[dict[str, str]]: + """Extract subsection detail cards.""" + cards: list[dict[str, str]] = [] sections = re.findall( - r"^### (?:\d+\.\s*)?(.+?)\n((?:(?!^###).)+)", content, re.MULTILINE | re.DOTALL + r"^### (?:\d+\.\s*)?(.+?)\n((?:(?!^###).)+)", + content, + re.MULTILINE | re.DOTALL, ) - for header, body in sections: - header = header.strip() - body = body.strip() + for raw_header, raw_body in sections: + header = raw_header.strip() + body = raw_body.strip() - # Skip very short sections or example sections - if len(body) < 50 or header.lower().startswith("przykład"): + if ( + len(body) < MIN_BODY_LENGTH + or header.lower().startswith("przykład") + ): continue - # Extract key information from body - answer_parts = [] - - # Look for #### sub-headers - subheaders = re.findall(r"^#### (.+)$", body, re.MULTILINE) - if subheaders: - answer_parts.extend(subheaders[:4]) - - # Look for bullet points with bold terms - bullets = re.findall(r"[-•]\s*\*\*([^*]+)\*\*[:\s-]*([^\n]+)?", body) - for term, desc in bullets[:5]: - if desc: - answer_parts.append(f"{term}: {desc.strip()}") - else: - answer_parts.append(f"{term}") - - # If no structured content, get first paragraph - if not answer_parts: - paras = [ - p.strip() - for p in body.split("\n\n") - if p.strip() - and not p.strip().startswith("```") - and not p.strip().startswith("|") - ] - if paras: - first = paras[0] - # Limit length - if len(first) > 300: - first = first[:300] + "..." - answer_parts.append(first) + answer_parts = _extract_body_parts(body) if answer_parts: - # Determine card type - if "Definicja" in header or "Co to" in header: - q = f"Co to jest: {header.replace('Definicja', '').strip()}?" - elif "Charakterystyka" in header: - q = f"Scharakteryzuj: {header.replace('Charakterystyka', '').strip()}" - elif header.endswith("?"): - q = header - else: - q = f"Omów: {header}" - - # Format answer + question = _make_question_text(header) if len(answer_parts) > 1: answer_html = format_list(answer_parts) else: @@ -163,15 +203,20 @@ def extract_from_file(filepath) -> list[dict[str, str]]: cards.append( { - "front": clean_text(q), + "front": clean_text(question), "back": answer_html, "tags": f"{base_tags} szczegoly", } ) - # ===================================================== - # CARD TYPE 3: Algorithms/Formulas - # ===================================================== + return cards + + +def _extract_algo_cards( + content: str, base_tags: str, +) -> list[dict[str, str]]: + """Extract algorithm/formula cards.""" + cards: list[dict[str, str]] = [] algo_patterns = [ r"#### Złożoność(?:\s+czasowa)?\s*\n(.+?)(?=\n####|\n###|\Z)", r"Złożoność:\s*\*\*([^*]+)\*\*", @@ -179,85 +224,137 @@ def extract_from_file(filepath) -> list[dict[str, str]]: for pattern in algo_patterns: matches = re.findall(pattern, content, re.DOTALL) - for match in matches[:2]: - if len(match) > 10: - # Find context - which algorithm? + for algo_match in matches[:2]: + if len(algo_match) > MIN_MATCH_LENGTH: algo_context = re.search( - r"### (\d+\.\s*)?(.+?)(?=\n)", content[: content.find(match)] + r"### (\d+\.\s*)?(.+?)(?=\n)", + content[: content.find(algo_match)], ) if algo_context: algo_name = algo_context.group(2).strip() cards.append( { - "front": f"Jaka jest złożoność algorytmu/metody: {algo_name}?", - "back": clean_text(match.strip()[:200]), + "front": ( + "Jaka jest złożoność" + f" algorytmu/metody: {algo_name}?" + ), + "back": clean_text( + algo_match.strip()[:200] + ), "tags": f"{base_tags} zlozonosc", } ) break - # ===================================================== - # CARD TYPE 4: Comparisons (when file contains comparisons) - # ===================================================== + return cards + + +def _extract_comparison_cards( + content: str, base_tags: str, num: str, +) -> list[dict[str, str]]: + """Extract comparison cards.""" compare_match = re.search( r"## .*(Porównanie|Zestawienie|vs).*\n(.+?)(?=\n## |\Z)", content, re.DOTALL | re.IGNORECASE, ) - if compare_match: - compare_section = compare_match.group(2) - # Extract comparison items - items = re.findall(r"\|\s*\*\*([^|*]+)\*\*\s*\|([^|]+)\|", compare_section) - if items: - comparison_html = "" - for aspect, value in items[:6]: - comparison_html += f"" - comparison_html += "
AspektWartość
{clean_text(aspect)}{clean_text(value)}
" + if not compare_match: + return [] - # Get comparison title - title_match = re.search( - r"## .*(Porównanie|Zestawienie).*?(\w+.*?(?:vs|i|oraz).*?\w+)", - compare_match.group(0), - re.IGNORECASE, - ) - if title_match: - cards.append( - { - "front": f"Porównaj kluczowe różnice w temacie: pytanie {num}", - "back": comparison_html, - "tags": f"{base_tags} porownanie", - } - ) + compare_section = compare_match.group(2) + items = re.findall( + r"\|\s*\*\*([^|*]+)\*\*\s*\|([^|]+)\|", + compare_section, + ) + if not items: + return [] - # ===================================================== - # CARD TYPE 5: Q&A from practice questions section - # ===================================================== - qa_section = re.search(r"## 🎓 Pytania.*?\n(.+?)(?=\n## |\Z)", content, re.DOTALL) - if qa_section: - qa_content = qa_section.group(1) - # Find Q&A pairs - qas = re.findall( - r'### Q\d+:?\s*["\']?(.+?)["\']?\s*\n.*?Odpowiedź:\s*\n?(.+?)(?=\n### |\Z)', - qa_content, - re.DOTALL, + comparison_html = ( + "" + ) + for aspect, value in items[:MAX_COMPARISON_ITEMS]: + comparison_html += ( + f"" + f"" ) - for q, a in qas[:3]: - q = re.sub(r"\s+", " ", q.strip()) - a = a.strip() - if len(a) > 30: - # Limit answer length - a_lines = a.split("\n") - a_short = "\n".join(a_lines[:5]) - if len(a_short) > 400: - a_short = a_short[:400] + "..." + comparison_html += "
AspektWartość
{clean_text(aspect)}{clean_text(value)}
" - cards.append( - { - "front": clean_text(q), - "back": clean_text(a_short).replace("\n", "
"), - "tags": f"{base_tags} egzamin_praktyka", - } - ) + title_match = re.search( + r"## .*(Porównanie|Zestawienie)" + r".*?(\w+.*?(?:vs|i|oraz).*?\w+)", + compare_match.group(0), + re.IGNORECASE, + ) + if not title_match: + return [] + + return [ + { + "front": ( + "Porównaj kluczowe różnice" + f" w temacie: pytanie {num}" + ), + "back": comparison_html, + "tags": f"{base_tags} porownanie", + } + ] + + +def _extract_qa_cards( + content: str, base_tags: str, +) -> list[dict[str, str]]: + """Extract Q&A practice cards.""" + cards: list[dict[str, str]] = [] + qa_section = re.search( + r"## 🎓 Pytania.*?\n(.+?)(?=\n## |\Z)", + content, + re.DOTALL, + ) + if not qa_section: + return cards + + qa_content = qa_section.group(1) + qas = re.findall( + r"### Q\d+:?\s*[\"']?(.+?)[\"']?\s*\n" + r".*?Odpowiedź:\s*\n?(.+?)(?=\n### |\Z)", + qa_content, + re.DOTALL, + ) + for raw_q, raw_a in qas[:3]: + question = re.sub(r"\s+", " ", raw_q.strip()) + answer = raw_a.strip() + if len(answer) > MIN_QA_LENGTH: + a_lines = answer.split("\n") + a_short = "\n".join(a_lines[:5]) + if len(a_short) > MAX_ANSWER_LENGTH: + a_short = a_short[:MAX_ANSWER_LENGTH] + "..." + + cards.append( + { + "front": clean_text(question), + "back": clean_text(a_short).replace( + "\n", "
" + ), + "tags": f"{base_tags} egzamin_praktyka", + } + ) + + return cards + + +def extract_from_file(filepath: str) -> list[dict[str, str]]: + """Extract flashcard data from a markdown file.""" + num, subject, content = _get_file_metadata(filepath) + base_tags = f"egzamin_magisterski pyt{num} {subject}" + + cards: 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_qa_cards(content, base_tags)) return cards @@ -272,13 +369,13 @@ def main() -> None: all_cards = [] for md_file in sorted(odpowiedzi_dir.glob("*.md")): - print(f"Processing: {md_file.name}", end=" ") + logger.info("Processing: %s", md_file.name) try: cards = extract_from_file(md_file) all_cards.extend(cards) - print(f"→ {len(cards)} cards") - except Exception as e: - print(f"→ ERROR: {e}") + logger.info(" -> %d cards", len(cards)) + except (ValueError, OSError) as e: + logger.info(" -> ERROR: %s", e) # Remove potential duplicates (same front) seen = set() @@ -306,23 +403,25 @@ def main() -> None: f.write(f"{front}\t{back}\t{tags}\n") - print(f"\n{'=' * 50}") - print(f"✅ Generated {len(unique_cards)} unique flashcards") - print(f"📁 Saved to: {output_file}") - print(f"{'=' * 50}") - print("\n📋 IMPORT INSTRUCTIONS:") - print("─" * 40) - print("Anki Desktop:") - print(" 1. File → Import") - print(" 2. Select: anki_egzamin_magisterski.txt") - print(" 3. Verify: Fields separated by Tab") - print(" 4. Check: Allow HTML in fields") - print(" 5. Click Import") - print() - print("AnkiWeb / AnkiDroid:") - print(" 1. First import on Anki Desktop") - print(" 2. Click Sync to upload to AnkiWeb") - print(" 3. Sync on mobile to download") + logger.info("=" * 50) + logger.info( + "Generated %d unique flashcards", len(unique_cards) + ) + logger.info("Saved to: %s", output_file) + logger.info("=" * 50) + logger.info("IMPORT INSTRUCTIONS:") + logger.info("-" * 40) + logger.info("Anki Desktop:") + logger.info(" 1. File -> Import") + logger.info(" 2. Select: anki_egzamin_magisterski.txt") + logger.info(" 3. Verify: Fields separated by Tab") + logger.info(" 4. Check: Allow HTML in fields") + logger.info(" 5. Click Import") + logger.info("") + logger.info("AnkiWeb / AnkiDroid:") + logger.info(" 1. First import on Anki Desktop") + logger.info(" 2. Click Sync to upload to AnkiWeb") + logger.info(" 3. Sync on mobile to download") if __name__ == "__main__": diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_anki_v2.py b/python_pkg/praca_magisterska_video/generate_images/generate_anki_v2.py index c659fc2..fe4425b 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_anki_v2.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_anki_v2.py @@ -6,12 +6,16 @@ Creates a tab-separated file compatible with Anki import. from __future__ import annotations +import logging from pathlib import Path import re -import traceback + +logger = logging.getLogger(__name__) + +MIN_HEADER_WORDS = 3 -def extract_main_question(content, filename) -> str: +def extract_main_question(content: str, filename: str) -> str: """Extract the main exam question from the file.""" # Extract the main question from ## Pytanie section question_match = re.search( @@ -26,13 +30,13 @@ def extract_main_question(content, filename) -> str: return title_match.group(1) if title_match else filename -def extract_subject(content) -> str: +def extract_subject(content: str) -> str: """Extract the subject code.""" subject_match = re.search(r"Przedmiot:\s*(\w+)", content) return subject_match.group(1) if subject_match else "Ogólne" -def extract_key_points(content) -> list[str]: +def extract_key_points(content: str) -> list[str]: """Extract key points from the main answer section.""" points = [] @@ -51,14 +55,14 @@ def extract_key_points(content) -> list[str]: headers = re.findall(r"^### (.+)$", answer_text, re.MULTILINE) for h in headers[:6]: # Clean header - h = re.sub(r"\d+\.\s*", "", h).strip() - if h and len(h) > 3: - points.append(h) + cleaned = re.sub(r"\d+\.\s*", "", h).strip() + if cleaned and len(cleaned) > MIN_HEADER_WORDS: + points.append(cleaned) return points -def extract_definitions(content) -> list[tuple[str, str]]: +def extract_definitions(content: str) -> list[tuple[str, str]]: """Extract key definitions from the content.""" definitions = [] @@ -66,9 +70,9 @@ def extract_definitions(content) -> list[tuple[str, str]]: pattern = r"\*\*([^*\n]+)\*\*\s*[--:]\s*([^*\n]{20,150})" matches = re.findall(pattern, content) - for term, definition in matches: - term = term.strip() - definition = definition.strip() + for raw_term, raw_def in matches: + term = raw_term.strip() + definition = raw_def.strip() # Filter out non-definition patterns if ( term @@ -81,7 +85,7 @@ def extract_definitions(content) -> list[tuple[str, str]]: return definitions[:5] -def clean_html(text) -> str: +def clean_html(text: str) -> str: """Convert markdown to HTML and clean for Anki.""" if not text: return "" @@ -101,7 +105,7 @@ def clean_html(text) -> str: return text.strip() -def process_file(filepath) -> list[dict[str, str]]: +def process_file(filepath: str) -> list[dict[str, str]]: """Process a single file and return flashcards.""" with Path(filepath).open(encoding="utf-8") as f: content = f.read() @@ -111,11 +115,7 @@ def process_file(filepath) -> list[dict[str, str]]: # Extract metadata filename = Path(filepath).name match = re.match(r"(\d+)-(.+)\.md", filename) - if match: - num = match.group(1) - match.group(2).replace("-", "_") - else: - num = "00" + num = match.group(1) if match else "00" subject = extract_subject(content) main_question = extract_main_question(content, filename) @@ -156,14 +156,13 @@ def main() -> None: # Process each file for md_file in sorted(odpowiedzi_dir.glob("*.md")): - print(f"Processing: {md_file.name}") + logger.info("Processing: %s", md_file.name) try: cards = process_file(md_file) all_cards.extend(cards) - print(f" -> {len(cards)} cards") - except Exception as e: - print(f" -> Error: {e}") - traceback.print_exc() + logger.info(" -> %d cards", len(cards)) + except (ValueError, OSError): + logger.exception(" -> Error processing file") # Write Anki-compatible file with Path(output_file).open("w", encoding="utf-8") as f: @@ -186,16 +185,22 @@ def main() -> None: f.write(f"{front}\t{back}\t{tags}\n") - print(f"\n✅ Created {len(all_cards)} flashcards") - print(f"📁 Output: {output_file}") - print("\n=== Import Instructions ===") - print("1. Open Anki desktop → File → Import") - print("2. Select: anki_egzamin_magisterski.txt") - print("3. Set 'Fields separated by: Tab'") - print("4. Check 'Allow HTML in fields'") - print("5. Map: Field 1 → Front, Field 2 → Back, Field 3 → Tags") - print("6. Click Import") - print("\nFor AnkiWeb/AnkiDroid: Sync after importing on desktop") + logger.info("Created %d flashcards", len(all_cards)) + logger.info("Output: %s", output_file) + logger.info("=== Import Instructions ===") + logger.info("1. Open Anki desktop -> File -> Import") + 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("6. Click Import") + logger.info( + "For AnkiWeb/AnkiDroid:" + " Sync after importing on desktop" + ) if __name__ == "__main__": diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_anki_v3.py b/python_pkg/praca_magisterska_video/generate_images/generate_anki_v3.py index 148729c..f86effd 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_anki_v3.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_anki_v3.py @@ -3,11 +3,18 @@ from __future__ import annotations +import logging from pathlib import Path import re +logger = logging.getLogger(__name__) -def clean_text(text) -> str: +MIN_PARA_LENGTH = 20 +MAX_PARA_LENGTH = 400 +MIN_BODY_LENGTH = 80 + + +def clean_text(text: str) -> str: """Clean text for Anki.""" if not text: return "" @@ -19,7 +26,7 @@ def clean_text(text) -> str: return text.strip() -def extract_real_answer(content, section_name) -> str | None: +def extract_real_answer(content: str, section_name: str) -> str | None: """Extract actual content from a section, not just headers.""" # Find the section pattern = rf"### (?:\d+\.\s*)?{re.escape(section_name)}\s*\n((?:(?!^### ).)+)" @@ -52,19 +59,21 @@ def extract_real_answer(content, section_name) -> str | None: for p in body.split("\n\n") if p.strip() and not p.startswith("```") and not p.startswith("|") ] - for p in paras[:2]: - if len(p) > 20 and len(p) < 400: - lines.append(p) + lines.extend( + p for p in paras[:2] + if len(p) > MIN_PARA_LENGTH and len(p) < MAX_PARA_LENGTH + ) return "
".join(lines[:6]) if lines else None -def extract_cards(filepath) -> list[dict[str, str]]: - """Extract flashcards from a file.""" +def _read_file_metadata( + filepath: str | Path, +) -> tuple[str, str, str | None]: + """Read file and extract metadata.""" with Path(filepath).open(encoding="utf-8") as f: content = f.read() - cards = [] filename = Path(filepath).name match = re.match(r"(\d+)-(.+)\.md", filename) num = match.group(1) if match else "00" @@ -73,182 +82,228 @@ def extract_cards(filepath) -> list[dict[str, str]]: subject = subj_match.group(1) if subj_match else "Ogólne" base_tags = f"egzamin_magisterski pyt{num} {subject}" - # Get main question q_match = re.search( - r'## Pytanie\s*\n\s*\*\*["\']?(.+?)["\']?\*\*', content, re.DOTALL + r'## Pytanie\s*\n\s*\*\*["\']?(.+?)["\']?\*\*', + 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 - # =============================================== - # MAIN CARD: Question with REAL answer summary - # =============================================== - if main_question: - # Build a real answer from the main sections - answer_parts = [] + return content, base_tags, main_question - # For automata question - extract key facts about each automaton - if "automat" in main_question.lower() or "maszyn" in main_question.lower(): - # FA - fa_match = re.search( - r"Automat Skończony.*?Rozpoznawana klasa języków\s*\n\s*\*\*([^*]+)\*\*", - content, - re.DOTALL, + +def _extract_automata_facts(content: str) -> list[str]: + """Extract automata-specific facts.""" + parts: list[str] = [] + automata = [ + ("Automat Skończony", "FA"), + ("Automat ze Stosem", "PDA"), + ("Maszyna Turinga", "TM"), + ] + for name, abbrev in automata: + pattern = ( + rf"{name}.*?Rozpoznawana klasa języków" + r"\s*\n\s*\*\*([^*]+)\*\*" + ) + match = re.search(pattern, content, re.DOTALL) + if match: + parts.append( + f"{name} ({abbrev}): " + f"{match.group(1).strip()}" ) - if fa_match: - answer_parts.append( - f"Automat Skończony (FA): {fa_match.group(1).strip()}" - ) + return parts - # PDA - pda_match = re.search( - r"Automat ze Stosem.*?Rozpoznawana klasa języków\s*\n\s*\*\*([^*]+)\*\*", - content, - re.DOTALL, - ) - if pda_match: - answer_parts.append( - f"Automat ze Stosem (PDA): {pda_match.group(1).strip()}" - ) - # TM - tm_match = re.search( - r"Maszyna Turinga.*?Rozpoznawana klasa języków\s*\n\s*\*\*([^*]+)\*\*", - content, - re.DOTALL, - ) - if tm_match: - answer_parts.append( - f"Maszyna Turinga (TM): {tm_match.group(1).strip()}" - ) +def _extract_generic_facts(content: str) -> list[str]: + """Extract generic definitions and summaries.""" + parts: list[str] = [] + key_patterns = [ + r"#### Definicja\s*\n([^\n#]+)", + r"#### Charakterystyka\s*\n([^\n#]+)", + r"\*\*Definicja[:\s]*\*\*\s*([^\n]+)", + ] + for pattern in key_patterns: + parts.extend( + found.strip() + for found in re.findall(pattern, content)[:3] + if len(found) > MIN_PARA_LENGTH + ) + return parts - # Generic extraction if specific didn't work - if not answer_parts: - # Look for key definitions/summaries - key_patterns = [ - r"#### Definicja\s*\n([^\n#]+)", - r"#### Charakterystyka\s*\n([^\n#]+)", - r"\*\*Definicja[:\s]*\*\*\s*([^\n]+)", - ] - for pattern in key_patterns: - for match in re.findall(pattern, content)[:3]: - if len(match) > 20: - answer_parts.append(match.strip()) - # Still nothing? Get first substantive paragraph from main answer - if not answer_parts: - main_answer = re.search( - r"## 📚 Odpowiedź główna\s*\n(.+?)(?=\n## |\Z)", content, re.DOTALL - ) - if main_answer: - # Skip headers, get actual content - text = main_answer.group(1) - paras = re.findall(r"\n\n([^#\n][^\n]{50,300})", text) - answer_parts = paras[:3] +def _extract_first_paragraphs(content: str) -> list[str]: + """Extract first substantive paragraphs from main answer.""" + main_answer = re.search( + r"## 📚 Odpowiedź główna\s*\n(.+?)(?=\n## |\Z)", + content, + re.DOTALL, + ) + if not main_answer: + return [] + text = main_answer.group(1) + paras = re.findall(r"\n\n([^#\n][^\n]{50,300})", text) + return paras[:3] - if answer_parts: - answer = "

".join([clean_text(p) for p in answer_parts]) - cards.append( - { - "front": clean_text(main_question), - "back": answer, - "tags": f"{base_tags} pytanie_glowne", - } + +def _build_main_card( + content: str, + main_question: str | None, + base_tags: str, +) -> dict[str, str] | None: + """Build the main question card.""" + if not main_question: + return None + + answer_parts: list[str] = [] + if ( + "automat" in main_question.lower() + or "maszyn" in main_question.lower() + ): + answer_parts = _extract_automata_facts(content) + + if not answer_parts: + answer_parts = _extract_generic_facts(content) + + if not answer_parts: + answer_parts = _extract_first_paragraphs(content) + + if not answer_parts: + return None + + answer = "

".join( + clean_text(p) for p in answer_parts + ) + return { + "front": clean_text(main_question), + "back": answer, + "tags": f"{base_tags} pytanie_glowne", + } + + +def _extract_section_content(body: str) -> list[str]: + """Extract content lines from a section body.""" + answer_lines: list[str] = [] + + def_match = re.search( + 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, + ) + if char_match: + bullets = re.findall( + r"[-•]\s*\*\*([^*]+)\*\*[:\s]*([^\n]*)", + char_match.group(1), + ) + for term, desc in bullets[:4]: + answer_lines.append( + f"• {term}: {desc.strip()}" + if desc + else f"• {term}" ) - # =============================================== - # CONCEPT CARDS: Specific topics with real content - # =============================================== - # Find all ### sections and extract their actual content + if not answer_lines: + bullets = re.findall( + r"[-•]\s*\*\*([^*]+)\*\*[:\s]*([^\n]*)", body, + ) + for term, desc in bullets[:5]: + answer_lines.append( + f"• {term}: {desc.strip()}" + if desc + else f"• {term}" + ) + + if not answer_lines: + first_para = re.search( + r"^([^#\n\-•|`][^\n]{30,250})", body, re.MULTILINE, + ) + if first_para: + answer_lines.append(first_para.group(1)) + + return answer_lines + + +def _build_concept_cards( + content: str, base_tags: str, +) -> list[dict[str, str]]: + """Build concept cards from ### sections.""" + cards: list[dict[str, str]] = [] sections = re.findall( r"^### (?:\d+\.\s*)?([^\n]+)\n((?:(?!^### ).)*)", content, re.MULTILINE | re.DOTALL, ) - for header, body in sections: - header = header.strip() - body = body.strip() + for raw_header, raw_body in sections: + header = raw_header.strip() + body = raw_body.strip() - # Skip short sections, mnemonics, examples if ( - len(body) < 80 + len(body) < MIN_BODY_LENGTH or "Przykład" in header or "Mnemonic" in header or '"' in header ): continue - # Extract real content - answer_lines = [] - - # Get definition if present - def_match = re.search(r"#### Definicja[^\n]*\n([^\n#]+(?:\n[^\n#]+)?)", body) - if def_match: - answer_lines.append(def_match.group(1).strip()) - - # Get characterization - char_match = re.search(r"#### Charakterystyka\s*\n((?:[-•][^\n]+\n?)+)", body) - if char_match: - bullets = re.findall( - r"[-•]\s*\*\*([^*]+)\*\*[:\s]*([^\n]*)", char_match.group(1) - ) - for term, desc in bullets[:4]: - answer_lines.append( - f"• {term}: {desc.strip()}" if desc else f"• {term}" - ) - - # Get bullet points if no structured content yet + answer_lines = _extract_section_content(body) if not answer_lines: - bullets = re.findall(r"[-•]\s*\*\*([^*]+)\*\*[:\s]*([^\n]*)", body) - for term, desc in bullets[:5]: - answer_lines.append( - f"• {term}: {desc.strip()}" if desc else f"• {term}" - ) + continue - # Get first paragraph if still nothing - if not answer_lines: - first_para = re.search(r"^([^#\n\-•|`][^\n]{30,250})", body, re.MULTILINE) - if first_para: - answer_lines.append(first_para.group(1)) + question = ( + header if header.endswith("?") else f"Wyjaśnij: {header}" + ) + answer = "
".join( + clean_text(line) for line in answer_lines + ) + cards.append( + { + "front": clean_text(question), + "back": answer, + "tags": f"{base_tags} szczegoly", + } + ) - if answer_lines: - question = f"Wyjaśnij: {header}" if not header.endswith("?") else header - answer = "
".join([clean_text(l) for l in answer_lines]) + return cards - cards.append( - { - "front": clean_text(question), - "back": answer, - "tags": f"{base_tags} szczegoly", - } - ) - # =============================================== - # Q&A CARDS: From practice questions section - # =============================================== +def _build_qa_cards( + content: str, base_tags: str, +) -> list[dict[str, str]]: + """Build Q&A practice cards.""" + cards: list[dict[str, str]] = [] qa_matches = re.findall( - r'### Q\d+:\s*["\']?([^"\'?\n]+)\?*["\']?\s*\n.*?Odpowiedź:\s*\n(.+?)(?=\n### |\n## |\Z)', + r'### Q\d+:\s*["\']?([^"\'?\n]+)\?*["\']?\s*\n' + r".*?Odpowiedź:\s*\n(.+?)(?=\n### |\n## |\Z)", content, re.DOTALL, ) - for question, answer in qa_matches[:5]: - question = question.strip() - answer = answer.strip() + for raw_question, raw_answer in qa_matches[:5]: + question = raw_question.strip() + answer_text = raw_answer.strip() - # Clean up answer - get first meaningful part - answer_lines = answer.split("\n") - clean_answer = [] - for line in answer_lines[:6]: - line = line.strip() - if line and not line.startswith("```") and not line.startswith("|"): - clean_answer.append(line) + answer_lines = answer_text.split("\n") + clean_answer = [ + stripped + for raw_line in answer_lines[:6] + if (stripped := raw_line.strip()) + and not stripped.startswith("```") + and not stripped.startswith("|") + ] if clean_answer: cards.append( { "front": clean_text(question + "?"), - "back": "
".join([clean_text(l) for l in clean_answer]), + "back": "
".join( + clean_text(line) for line in clean_answer + ), "tags": f"{base_tags} qa", } ) @@ -256,6 +311,20 @@ def extract_cards(filepath) -> list[dict[str, str]]: return cards +def extract_cards(filepath: str | Path) -> list[dict[str, str]]: + """Extract flashcards from a file.""" + content, base_tags, main_question = _read_file_metadata(filepath) + + cards: list[dict[str, str]] = [] + main_card = _build_main_card(content, main_question, base_tags) + if main_card: + cards.append(main_card) + + cards.extend(_build_concept_cards(content, base_tags)) + cards.extend(_build_qa_cards(content, base_tags)) + return cards + + def main() -> None: """Main.""" odpowiedzi_dir = Path("/home/kuchy/praca_magisterska/pytania/odpowiedzi") @@ -266,13 +335,13 @@ def main() -> None: all_cards = [] for md_file in sorted(odpowiedzi_dir.glob("*.md")): - print(f"Processing: {md_file.name}", end=" ") + logger.info("Processing: %s", md_file.name) try: cards = extract_cards(md_file) all_cards.extend(cards) - print(f"→ {len(cards)} cards") - except Exception as e: - print(f"→ ERROR: {e}") + logger.info(" -> %d cards", len(cards)) + except (ValueError, OSError): + logger.exception(" -> Error processing file") # Remove duplicates seen = set() @@ -299,8 +368,12 @@ def main() -> None: tags = card["tags"] f.write(f"{front}\t{back}\t{tags}\n") - print(f"\n✅ Generated {len(unique_cards)} flashcards") - print(f"📁 Output: {output_file}") + logger.info( + "Generated %d unique cards from %d total", + len(unique_cards), + len(all_cards), + ) + logger.info("Output: %s", output_file) if __name__ == "__main__": diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_automata_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_automata_diagrams.py index c07f7ba..54353f8 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_automata_diagrams.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_automata_diagrams.py @@ -9,14 +9,25 @@ All: A4-compatible, B&W, 300 DPI, laser-printer-friendly. """ +from __future__ import annotations + +from dataclasses import dataclass +import logging +from pathlib import Path +from typing import TYPE_CHECKING + import matplotlib as mpl mpl.use("Agg") -from pathlib import Path import matplotlib.patches as mpatches import matplotlib.pyplot as plt +if TYPE_CHECKING: + from matplotlib.axes import Axes + +logger = logging.getLogger(__name__) + DPI = 300 BG = "white" LN = "black" @@ -36,26 +47,82 @@ 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, x, y, r, label, accepting=False, initial=False, fillcolor="white", fontsize=FS + 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=fillcolor, edgecolor=LN, linewidth=1.5, zorder=3 + (x, y), + r, + fill=True, + facecolor=s.fillcolor, + edgecolor=LN, + linewidth=1.5, + zorder=3, ) ax.add_patch(circle) - if accepting: + if s.accepting: inner = plt.Circle( - (x, y), r * 0.82, fill=False, edgecolor=LN, linewidth=1.2, zorder=3 + (x, y), + r * INNER_RATIO, + fill=False, + edgecolor=LN, + linewidth=1.2, + zorder=3, ) ax.add_patch(inner) - if initial: + if s.initial: ax.annotate( "", xy=(x - r, y), - xytext=(x - r - 0.4, y), - arrowprops={"arrowstyle": "->", "color": LN, "lw": 1.5}, + xytext=(x - r - ARROW_OFFSET, y), + arrowprops={ + "arrowstyle": "->", + "color": LN, + "lw": 1.5, + }, zorder=4, ) ax.text( @@ -64,25 +131,23 @@ def draw_state_circle( label, ha="center", va="center", - fontsize=fontsize, + fontsize=s.fontsize, fontweight="bold", zorder=5, ) def draw_curved_arrow( - ax, - x1, - y1, - x2, - y2, - label, - _r=0.25, - connectionstyle="arc3,rad=0.3", - fontsize=FS_SMALL, - label_offset=(0, 0), + 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), @@ -91,19 +156,19 @@ def draw_curved_arrow( "arrowstyle": "->", "color": LN, "lw": 1.2, - "connectionstyle": connectionstyle, + "connectionstyle": s.connectionstyle, }, zorder=2, ) - mx = (x1 + x2) / 2 + label_offset[0] - my = (y1 + y2) / 2 + label_offset[1] + 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=fontsize, + fontsize=s.fontsize, fontstyle="italic", zorder=5, bbox={ @@ -115,15 +180,23 @@ def draw_curved_arrow( ) -def draw_self_loop(ax, x, y, r, label, direction="top", fontsize=FS_SMALL) -> None: +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.""" - if direction == "top": + s = style or LoopStyle() + x, y = pos + if s.direction == "top": loop = mpatches.FancyArrowPatch( - (x - 0.12, y + r), - (x + 0.12, y + r), - connectionstyle="arc3,rad=-1.8", + (x - LOOP_OFFSET, y + r), + (x + LOOP_OFFSET, y + r), + connectionstyle=f"arc3,rad=-{LOOP_RAD}", arrowstyle="->", - mutation_scale=12, + mutation_scale=MUTATION_SCALE, lw=1.2, color=LN, zorder=2, @@ -131,21 +204,21 @@ def draw_self_loop(ax, x, y, r, label, direction="top", fontsize=FS_SMALL) -> No ax.add_patch(loop) ax.text( x, - y + r + 0.35, + y + r + LOOP_LABEL_OFFSET, label, ha="center", va="center", - fontsize=fontsize, + fontsize=s.fontsize, fontstyle="italic", zorder=5, ) - elif direction == "bottom": + elif s.direction == "bottom": loop = mpatches.FancyArrowPatch( - (x - 0.12, y - r), - (x + 0.12, y - r), - connectionstyle="arc3,rad=1.8", + (x - LOOP_OFFSET, y - r), + (x + LOOP_OFFSET, y - r), + connectionstyle=f"arc3,rad={LOOP_RAD}", arrowstyle="->", - mutation_scale=12, + mutation_scale=MUTATION_SCALE, lw=1.2, color=LN, zorder=2, @@ -153,11 +226,11 @@ def draw_self_loop(ax, x, y, r, label, direction="top", fontsize=FS_SMALL) -> No ax.add_patch(loop) ax.text( x, - y - r - 0.35, + y - r - LOOP_LABEL_OFFSET, label, ha="center", va="center", - fontsize=fontsize, + fontsize=s.fontsize, fontstyle="italic", zorder=5, ) @@ -169,7 +242,10 @@ def draw_self_loop(ax, x, y, r, label, direction="top", fontsize=FS_SMALL) -> No 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]} + 1, + 2, + figsize=(11.69, 4), + gridspec_kw={"width_ratios": [1, 1.3]}, ) # --- Left: State diagram --- @@ -179,69 +255,99 @@ def draw_fa_recognition() -> None: ax.set_aspect("equal") ax.axis("off") ax.set_title( - 'DFA — diagram stanów\nL = {słowa nad {a,b} kończące się na "ab"}', + "DFA — diagram stanów\n" + 'L = {słowa nad {a,b} kończące się na "ab"}', fontsize=FS_TITLE, fontweight="bold", pad=10, ) - R = 0.35 - # States positions - states = {"q₀": (0.8, 0.5), "q₁": (2.8, 0.5), "q₂": (4.8, 0.5)} + 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₀"], R, "q₀", initial=True) - draw_state_circle(ax, *states["q₁"], R, "q₁") - draw_state_circle(ax, *states["q₂"], R, "q₂", accepting=True, fillcolor=LIGHT_GREEN) + 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] + R, - states["q₀"][1] + 0.05, - states["q₁"][0] - R, - states["q₁"][1] + 0.05, + (states["q₀"][0] + state_r, states["q₀"][1] + 0.05), + (states["q₁"][0] - state_r, states["q₁"][1] + 0.05), "a", - connectionstyle="arc3,rad=0.15", - label_offset=(0, 0.25), + ArrowStyle( + connectionstyle="arc3,rad=0.15", + label_offset=(0, 0.25), + ), ) # q₁ --b--> q₂ draw_curved_arrow( ax, - states["q₁"][0] + R, - states["q₁"][1] + 0.05, - states["q₂"][0] - R, - states["q₂"][1] + 0.05, + (states["q₁"][0] + state_r, states["q₁"][1] + 0.05), + (states["q₂"][0] - state_r, states["q₂"][1] + 0.05), "b", - connectionstyle="arc3,rad=0.15", - label_offset=(0, 0.25), + ArrowStyle( + connectionstyle="arc3,rad=0.15", + label_offset=(0, 0.25), + ), ) # q₂ --a--> q₁ draw_curved_arrow( ax, - states["q₂"][0] - R, - states["q₂"][1] - 0.05, - states["q₁"][0] + R, - states["q₁"][1] - 0.05, + (states["q₂"][0] - state_r, states["q₂"][1] - 0.05), + (states["q₁"][0] + state_r, states["q₁"][1] - 0.05), "a", - connectionstyle="arc3,rad=0.15", - label_offset=(0, -0.3), + 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] - R, - states["q₀"][0] + 0.2, - states["q₀"][1] - R, + (states["q₂"][0] - 0.2, states["q₂"][1] - state_r), + (states["q₀"][0] + 0.2, states["q₀"][1] - state_r), "b", - connectionstyle="arc3,rad=0.4", - label_offset=(0, -0.4), + ArrowStyle( + connectionstyle="arc3,rad=0.4", + label_offset=(0, -0.4), + ), ) # q₀ --b--> q₀ (self-loop) - draw_self_loop(ax, *states["q₀"], R, "b", direction="top") + draw_self_loop( + ax, + states["q₀"], + state_r, + "b", + LoopStyle(direction="top"), + ) # q₁ --a--> q₁ (self-loop) - draw_self_loop(ax, *states["q₁"], R, "a", direction="top") + draw_self_loop( + ax, + states["q₁"], + state_r, + "a", + LoopStyle(direction="top"), + ) # Legend ax.text( @@ -251,18 +357,31 @@ def draw_fa_recognition() -> None: fontsize=FS_SMALL, ha="left", va="center", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + 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 + 'Ślad wykonania — wejście: "baab"', + fontsize=FS_TITLE, + fontweight="bold", + pad=10, ) trace_data = [ - ["Krok", "Czytam", "Stan przed", "Przejście", "Stan po"], + [ + "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₁"], @@ -277,7 +396,7 @@ def draw_fa_recognition() -> None: loc="center", bbox=[0.05, 0.15, 0.9, 0.75], ) - table.auto_set_font_size(False) + table.auto_set_font_size(auto=False) table.set_fontsize(FS) for (row, _col), cell in table.get_celld().items(): cell.set_edgecolor(GRAY3) @@ -297,7 +416,11 @@ def draw_fa_recognition() -> None: fontsize=FS + 1, fontweight="bold", transform=ax2.transAxes, - bbox={"boxstyle": "round,pad=0.4", "facecolor": LIGHT_GREEN, "edgecolor": LN}, + bbox={ + "boxstyle": "round,pad=0.4", + "facecolor": LIGHT_GREEN, + "edgecolor": LN, + }, ) plt.tight_layout() @@ -308,16 +431,19 @@ def draw_fa_recognition() -> None: facecolor=BG, ) plt.close() - print(" ✓ fa_recognition_example.png") + logger.info(" ✓ fa_recognition_example.png") # ============================================================ # 2. PDA Recognition Example — PDA for aⁿbⁿ # ============================================================ def draw_pda_recognition() -> None: - """PDA state diagram + step-by-step trace with stack visualization for 'aabb'.""" + """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]} + 1, + 2, + figsize=(11.69, 5.5), + gridspec_kw={"width_ratios": [1, 1.4]}, ) # --- Left: State diagram --- @@ -333,66 +459,107 @@ def draw_pda_recognition() -> None: pad=10, ) - R = 0.38 - states = {"q₀": (0.8, 0.5), "q₁": (2.8, 0.5), "q₂": (4.8, 0.5)} + 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₀"], R, "q₀", initial=True) - draw_state_circle(ax, *states["q₁"], R, "q₁") - draw_state_circle(ax, *states["q₂"], R, "q₂", accepting=True, fillcolor=LIGHT_GREEN) + 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] + R, - states["q₀"][1], - states["q₁"][0] - R, - states["q₁"][1], + (states["q₀"][0] + state_r, states["q₀"][1]), + (states["q₁"][0] - state_r, states["q₁"][1]), "b, A → ε\n(pop A)", - connectionstyle="arc3,rad=0.0", - label_offset=(0, 0.4), + ArrowStyle( + connectionstyle="arc3,rad=0.0", + label_offset=(0, 0.4), + ), ) # q₁ --ε,Z₀/Z₀--> q₂ draw_curved_arrow( ax, - states["q₁"][0] + R, - states["q₁"][1], - states["q₂"][0] - R, - states["q₂"][1], + (states["q₁"][0] + state_r, states["q₁"][1]), + (states["q₂"][0] - state_r, states["q₂"][1]), "ε, Z₀ → Z₀\n(akceptuj)", - connectionstyle="arc3,rad=0.0", - label_offset=(0, 0.45), + 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₀"], R, "a, Z₀ → AZ₀\na, A → AA\n(push A)", direction="top" + 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₁"], R, "b, A → ε\n(pop A)", direction="top") + 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)", + "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}, + 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"', + "Ślad wykonania z wizualizacją stosu" + ' — wejście: "aabb"', fontsize=FS_TITLE, fontweight="bold", pad=10, ) trace_data = [ - ["Krok", "Czytam", "Stan", "Stos (szczyt→)", "Operacja"], + [ + "Krok", + "Czytam", + "Stan", + "Stos (szczyt→)", + "Operacja", + ], ["start", "—", "q₀", "[Z₀]", "—"], ["1", "a", "q₀", "[A, Z₀]", "push A"], ["2", "a", "q₀", "[A, A, Z₀]", "push A"], @@ -416,7 +583,7 @@ def draw_pda_recognition() -> None: loc="center", bbox=[0.02, 0.08, 0.96, 0.82], ) - table.auto_set_font_size(False) + table.auto_set_font_size(auto=False) table.set_fontsize(FS) for (row, _col), cell in table.get_celld().items(): cell.set_edgecolor(GRAY3) @@ -430,14 +597,20 @@ def draw_pda_recognition() -> None: 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', + "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}, + bbox={ + "boxstyle": "round,pad=0.4", + "facecolor": LIGHT_GREEN, + "edgecolor": LN, + }, ) plt.tight_layout() @@ -448,7 +621,7 @@ def draw_pda_recognition() -> None: facecolor=BG, ) plt.close() - print(" ✓ pda_recognition_example.png") + logger.info(" ✓ pda_recognition_example.png") # ============================================================ @@ -468,16 +641,23 @@ def draw_lba_recognition() -> None: pad=10, ) - CELL_W = 0.9 - CELL_H = 0.7 - TAPE_X0 = 1.5 - HEAD_COLOR = "#FFD700" + cell_w = 0.9 + cell_h = 0.7 + tape_x0 = 1.5 + head_color = "#FFD700" - def draw_tape(y, cells, head_pos, label, step_label="") -> None: - """Draw a tape row with cells, head position highlighted.""" + 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, - y + CELL_H / 2, + tape_y + cell_h / 2, label, ha="right", va="center", @@ -485,44 +665,53 @@ def draw_lba_recognition() -> None: fontweight="bold", ) for i, (sym, color) in enumerate(cells): - x = TAPE_X0 + i * CELL_W - fc = HEAD_COLOR if i == head_pos else color + x = tape_x0 + i * cell_w + fc = head_color if i == head_pos else color rect = mpatches.FancyBboxPatch( - (x, y), - CELL_W, - CELL_H, + (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, - y + CELL_H / 2, + x + cell_w / 2, + tape_y + cell_h / 2, sym, ha="center", va="center", fontsize=FS + 2, - fontweight="bold" if sym in ("X", "Y", "Z") else "normal", + fontweight=bold, family="monospace", ) if head_pos is not None: - hx = TAPE_X0 + head_pos * CELL_W + CELL_W / 2 + hx = ( + tape_x0 + + head_pos * cell_w + + cell_w / 2 + ) ax.annotate( "▼", - xy=(hx, y + CELL_H), - xytext=(hx, y + CELL_H + 0.25), + xy=(hx, tape_y + cell_h), + xytext=(hx, tape_y + cell_h + 0.25), ha="center", va="bottom", - fontsize=8, + fontsize=HEAD_MARKER_FONTSIZE, color="black", ) if step_label: - sx = TAPE_X0 + 6 * CELL_W + 0.5 + sx = tape_x0 + 6 * cell_w + 0.5 ax.text( sx, - y + CELL_H / 2, + tape_y + cell_h / 2, step_label, ha="left", va="center", @@ -534,54 +723,84 @@ def draw_lba_recognition() -> None: }, ) - W = "white" - MK = GRAY1 # marked cell color + white = "white" + mk = GRAY1 # marked cell color # Row 1: Initial tape - y = 9.0 + tape_y = 9.0 draw_tape( - y, - [("a", W), ("a", W), ("b", W), ("b", W), ("c", W), ("c", W)], + tape_y, + [ + ("a", white), + ("a", white), + ("b", white), + ("b", white), + ("c", white), + ("c", white), + ], 0, "Początek", - "taśma = [a, a, b, b, c, c], głowica na 0", + step_label=( + "taśma = [a, a, b, b, c, c], głowica na 0" + ), ) # Row 2: After marking first 'a' - y = 7.8 + tape_y = 7.8 draw_tape( - y, - [("X", MK), ("a", W), ("b", W), ("b", W), ("c", W), ("c", W)], + tape_y, + [ + ("X", mk), + ("a", white), + ("b", white), + ("b", white), + ("c", white), + ("c", white), + ], 1, "R1, krok 1", - "zaznacz a→X, szukaj b", + step_label="zaznacz a→X, szukaj b", ) # Row 3: After marking first 'b' - y = 6.6 + tape_y = 6.6 draw_tape( - y, - [("X", MK), ("a", W), ("Y", MK), ("b", W), ("c", W), ("c", W)], + tape_y, + [ + ("X", mk), + ("a", white), + ("Y", mk), + ("b", white), + ("c", white), + ("c", white), + ], 3, "R1, krok 2", - "zaznacz b→Y, szukaj c", + step_label="zaznacz b→Y, szukaj c", ) # Row 4: After marking first 'c' - y = 5.4 + tape_y = 5.4 draw_tape( - y, - [("X", MK), ("a", W), ("Y", MK), ("b", W), ("Z", MK), ("c", W)], + tape_y, + [ + ("X", mk), + ("a", white), + ("Y", mk), + ("b", white), + ("Z", mk), + ("c", white), + ], 0, "R1, krok 3", - "zaznacz c→Z, wróć na początek", + step_label="zaznacz c→Z, wróć na początek", ) # Runda 2 header - y = 4.5 + tape_y = 4.5 ax.text( - TAPE_X0 + 3 * CELL_W, - y + 0.3, + tape_x0 + 3 * cell_w, + tape_y + 0.3, "═══ RUNDA 2 ═══", ha="center", va="center", @@ -591,53 +810,81 @@ def draw_lba_recognition() -> None: ) # Row 5: After marking second 'a' - y = 3.6 + tape_y = 3.6 draw_tape( - y, - [("X", MK), ("X", MK), ("Y", MK), ("b", W), ("Z", MK), ("c", W)], + tape_y, + [ + ("X", mk), + ("X", mk), + ("Y", mk), + ("b", white), + ("Z", mk), + ("c", white), + ], 2, "R2, krok 1", - "pomiń X, zaznacz a→X, szukaj b", + step_label="pomiń X, zaznacz a→X, szukaj b", ) # Row 6: After marking second 'b' - y = 2.4 + tape_y = 2.4 draw_tape( - y, - [("X", MK), ("X", MK), ("Y", MK), ("Y", MK), ("Z", MK), ("c", W)], + tape_y, + [ + ("X", mk), + ("X", mk), + ("Y", mk), + ("Y", mk), + ("Z", mk), + ("c", white), + ], 4, "R2, krok 2", - "pomiń Y, zaznacz b→Y, szukaj c", + step_label="pomiń Y, zaznacz b→Y, szukaj c", ) # Row 7: After marking second 'c' - y = 1.2 + tape_y = 1.2 draw_tape( - y, - [("X", MK), ("X", MK), ("Y", MK), ("Y", MK), ("Z", MK), ("Z", MK)], + tape_y, + [ + ("X", mk), + ("X", mk), + ("Y", mk), + ("Y", mk), + ("Z", mk), + ("Z", mk), + ], None, "R2, krok 3", - "zaznacz c→Z, wróć na początek", + step_label="zaznacz c→Z, wróć na początek", ) # Result - y = 0.0 + tape_y = 0.0 ax.text( - TAPE_X0 + 3 * CELL_W, - y + 0.3, - 'Wszystko zaznaczone → q_acc → "aabbcc" AKCEPTOWANE ✓', + 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}, + bbox={ + "boxstyle": "round,pad=0.4", + "facecolor": LIGHT_GREEN, + "edgecolor": LN, + }, ) # Key ax.text( - TAPE_X0 + 6 * CELL_W + 0.5, - y + 0.3, - 'Ograniczenie LBA:\ngłowica ≤ 6 komórek\n(= |w| = |"aabbcc"|)', + 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, @@ -656,36 +903,44 @@ def draw_lba_recognition() -> None: facecolor=BG, ) plt.close() - print(" ✓ lba_recognition_example.png") + logger.info(" ✓ lba_recognition_example.png") # ============================================================ # 4. TM Recognition Example — TM for 0ⁿ1ⁿ # ============================================================ def draw_tm_recognition() -> None: - """TM tape visualization for 0ⁿ1ⁿ with infinite tape shown.""" + """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", + "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" + cell_w = 0.9 + cell_h = 0.7 + tape_x0 = 1.5 + head_color = "#FFD700" - def draw_tape(y, cells, head_pos, label, step_label="") -> None: + 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, - y + CELL_H / 2, + tape_y + cell_h / 2, label, ha="right", va="center", @@ -693,16 +948,16 @@ def draw_tm_recognition() -> None: fontweight="bold", ) for i, (sym, color) in enumerate(cells): - x = TAPE_X0 + i * CELL_W - fc = HEAD_COLOR if i == head_pos else color + 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, y), - CELL_W, - CELL_H, + (x, tape_y), + cell_w, + cell_h, boxstyle="round,pad=0.03", lw=lw, edgecolor=LN, @@ -710,43 +965,51 @@ def draw_tm_recognition() -> None: 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, - y + CELL_H / 2, + x + cell_w / 2, + tape_y + cell_h / 2, sym, ha="center", va="center", fontsize=FS + 2, - fontweight="bold" if sym in ("X", "Y") else "normal", + fontweight=bold, family="monospace", - color=GRAY3 if sym == "⊔" else LN, + color=clr, ) # ∞ arrow - last_x = TAPE_X0 + len(cells) * CELL_W + last_x = tape_x0 + len(cells) * cell_w ax.annotate( "→ ∞", - xy=(last_x + 0.3, y + CELL_H / 2), + 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 + hx = ( + tape_x0 + + head_pos * cell_w + + cell_w / 2 + ) ax.annotate( "▼", - xy=(hx, y + CELL_H), - xytext=(hx, y + CELL_H + 0.25), + xy=(hx, tape_y + cell_h), + xytext=(hx, tape_y + cell_h + 0.25), ha="center", va="bottom", - fontsize=8, + fontsize=HEAD_MARKER_FONTSIZE, color="black", ) if step_label: - sx = TAPE_X0 + 8 * CELL_W + 0.8 + sx = tape_x0 + 8 * cell_w + 0.8 ax.text( sx, - y + CELL_H / 2, + tape_y + cell_h / 2, step_label, ha="left", va="center", @@ -758,45 +1021,37 @@ def draw_tm_recognition() -> None: }, ) - W = "white" - MK = GRAY1 - BL = "#F0F0F0" # blank cell + white = "white" + mk = GRAY1 + bl = "#F0F0F0" # blank cell - # Row 1: Initial - y = 9.0 - draw_tape( - y, - [("0", W), ("0", W), ("1", W), ("1", W), ("⊔", BL), ("⊔", BL), ("⊔", BL)], - 0, - "Początek", - "taśma = [0,0,1,1,⊔,⊔,...∞]", - ) - - # Row 2: Mark first 0 - y = 7.8 - draw_tape( - y, - [("X", MK), ("0", W), ("1", W), ("1", W), ("⊔", BL), ("⊔", BL), ("⊔", BL)], - 1, - "R1, krok 1", - "zaznacz 0→X, idź w prawo", - ) - - # Row 3: Skip to first 1, mark it - y = 6.6 - draw_tape( - y, - [("X", MK), ("0", W), ("Y", MK), ("1", W), ("⊔", BL), ("⊔", BL), ("⊔", BL)], - 0, - "R1, krok 2", - "zaznacz 1→Y, wróć na początek", - ) + 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 - y = 5.8 + runda2_y = 5.8 ax.text( - TAPE_X0 + 3.5 * CELL_W, - y + 0.3, + tape_x0 + 3.5 * cell_w, + runda2_y + 0.3, "═══ RUNDA 2 ═══", ha="center", va="center", @@ -804,56 +1059,37 @@ def draw_tm_recognition() -> None: fontweight="bold", ) - # Row 4: Mark second 0 - y = 4.8 - draw_tape( - y, - [("X", MK), ("X", MK), ("Y", MK), ("1", W), ("⊔", BL), ("⊔", BL), ("⊔", BL)], - 2, - "R2, krok 1", - "pomiń X, zaznacz 0→X", - ) - - # Row 5: Mark second 1 - y = 3.6 - draw_tape( - y, - [("X", MK), ("X", MK), ("Y", MK), ("Y", MK), ("⊔", BL), ("⊔", BL), ("⊔", BL)], - 0, - "R2, krok 2", - "pomiń Y, zaznacz 1→Y, wróć", - ) - - # Row 6: Check — all marked - y = 2.4 - draw_tape( - y, - [("X", MK), ("X", MK), ("Y", MK), ("Y", MK), ("⊔", BL), ("⊔", BL), ("⊔", BL)], - None, - "Sprawdzenie", - "brak niezaznaczonych → q_acc", - ) + 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 - y = 0.8 + tape_y = 0.8 ax.text( - TAPE_X0 + 3.5 * CELL_W, - y + 0.3, + 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}, + bbox={ + "boxstyle": "round,pad=0.4", + "facecolor": LIGHT_GREEN, + "edgecolor": LN, + }, ) - y = -0.3 + tape_y = -0.3 ax.text( - TAPE_X0 + 3.5 * CELL_W, - y + 0.3, - "Różnica TM vs LBA: taśma TM jest nieskończona (⊔ → ∞)\n" + 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 ⊔", + "TM: głowica może wyjść POZA wejście " + "i pisać na pustych ⊔", ha="center", va="center", fontsize=FS_SMALL, @@ -872,16 +1108,18 @@ def draw_tm_recognition() -> None: facecolor=BG, ) plt.close() - print(" ✓ tm_recognition_example.png") + logger.info(" ✓ tm_recognition_example.png") # ============================================================ # Main # ============================================================ if __name__ == "__main__": - print("Generating automata diagrams for PYTANIE 1...") + logger.info( + "Generating automata diagrams for PYTANIE 1..." + ) draw_fa_recognition() draw_pda_recognition() draw_lba_recognition() draw_tm_recognition() - print(f"\nAll diagrams saved to {OUTPUT_DIR}/") + logger.info("All diagrams saved to %s/", OUTPUT_DIR) diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_normalization_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_normalization_diagrams.py index ccc0e3e..27a554b 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_normalization_diagrams.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_normalization_diagrams.py @@ -7,6 +7,8 @@ Designed for A4 laser printer output (300 DPI, black & white). from __future__ import annotations +import logging + import matplotlib as mpl mpl.use("Agg") @@ -20,6 +22,8 @@ if TYPE_CHECKING: from matplotlib.axes import Axes from matplotlib.figure import Figure +logger = logging.getLogger(__name__) + OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) @@ -33,19 +37,35 @@ FIXED_COLOR = "#D0F0D0" # light green-ish gray for fixed FD_ARROW_COLOR = "#444444" +def _compute_col_widths( + headers: list[str], + rows: list[list[str]], +) -> list[float]: + """Auto-calculate column widths based on content.""" + col_widths: list[float] = [] + for c in range(len(headers)): + max_len = len(headers[c]) + for r in rows: + if c < len(r): + max_len = max(max_len, len(str(r[c]))) + col_widths.append(max(max_len * 0.08 + 0.1, 0.5)) + return col_widths + + def draw_table( - ax, - x, - y, - title, - headers, - rows, - col_widths=None, - highlight_cols=None, - highlight_rows=None, - highlight_cells=None, - strikethrough_cells=None, - title_fontsize=9, + ax: Axes, + x: float, + y: float, + title: str, + headers: list[str], + rows: list[list[str]], + *, + col_widths: list[float] | None = None, + highlight_cols: set[int] | None = None, + highlight_rows: set[int] | None = None, + highlight_cells: set[tuple[int, int]] | None = None, + strikethrough_cells: set[tuple[int, int]] | None = None, + title_fontsize: int = 9, ) -> tuple[float, float]: """Draw a single table on the axes at position (x, y). @@ -66,18 +86,10 @@ def draw_table( Returns: (width, height) of the drawn table """ - n_cols = len(headers) n_rows = len(rows) if col_widths is None: - # Auto-calculate based on content - col_widths = [] - for c in range(n_cols): - max_len = len(headers[c]) - for r in rows: - if c < len(r): - max_len = max(max_len, len(str(r[c]))) - col_widths.append(max(max_len * 0.08 + 0.1, 0.5)) + col_widths = _compute_col_widths(headers, rows) row_height = 0.22 total_width = sum(col_widths) @@ -172,7 +184,10 @@ def draw_table( return total_width, total_height + 0.25 # extra for title -def create_figure(width_inches=11.69, height_inches=8.27) -> tuple[Figure, Axes]: +def create_figure( + width_inches: float = 11.69, + height_inches: float = 8.27, +) -> tuple[Figure, Axes]: """Create A4 landscape figure.""" fig, ax = plt.subplots(1, 1, figsize=(width_inches, height_inches), dpi=DPI) ax.set_xlim(0, width_inches) @@ -182,7 +197,16 @@ def create_figure(width_inches=11.69, height_inches=8.27) -> tuple[Figure, Axes] return fig, ax -def add_arrow(ax, x1, y1, x2, y2, label="", color="black") -> None: +def add_arrow( + ax: Axes, + x1: float, + y1: float, + x2: float, + y2: float, + label: str = "", + *, + color: str = "black", +) -> None: """Draw an arrow with optional label.""" ax.annotate( "", @@ -205,7 +229,15 @@ def add_arrow(ax, x1, y1, x2, y2, label="", color="black") -> None: def add_label( - ax, x, y, text, fontsize=8, color="black", ha="left", style="normal" + ax: Axes, + x: float, + y: float, + text: str, + *, + fontsize: int = 8, + color: str = "black", + ha: str = "left", + style: str = "normal", ) -> None: """Add a text label.""" ax.text( @@ -289,7 +321,10 @@ def draw_0nf() -> None: ax, 0.8, 1.2, - "Zaleznosci funkcyjne: StID -> Imie, WydzialID | WydzialID -> NazwaWydzialu", + ( + "Zaleznosci funkcyjne: StID -> Imie, WydzialID" + " | WydzialID -> NazwaWydzialu" + ), fontsize=8, color="#333333", ) @@ -297,7 +332,10 @@ def draw_0nf() -> None: ax, 0.8, 0.9, - " KursID -> NazwaKursu | (StID,KursID) -> Prowadzacy | Prowadzacy -> KursID", + ( + " KursID -> NazwaKursu | (StID,KursID)" + " -> Prowadzacy | Prowadzacy -> KursID" + ), fontsize=8, color="#333333", ) @@ -309,7 +347,7 @@ def draw_0nf() -> None: pad_inches=0.2, ) plt.close(fig) - print("Generated: nf_0nf_table.png") + logger.info("Generated: nf_0nf_table.png") # ============================================================ @@ -399,7 +437,10 @@ def draw_1nf() -> None: ax, 0.5, 1.5, - " Imie, WydzialID, NazwaWydzialu zaleza TYLKO od StID (czesc klucza).", + ( + " Imie, WydzialID, NazwaWydzialu" + " zaleza TYLKO od StID (czesc klucza)." + ), fontsize=9, color="black", ) @@ -419,7 +460,7 @@ def draw_1nf() -> None: pad_inches=0.2, ) plt.close(fig) - print("Generated: nf_1nf_tables.png") + logger.info("Generated: nf_1nf_tables.png") # ============================================================ @@ -477,7 +518,10 @@ def draw_2nf() -> None: ax, 0.3, 3.3, - "KROK: Rozbito czesc. zaleznosci — atrybuty zalezne od czesci klucza wydzielone.", + ( + "KROK: Rozbito czesc. zaleznosci" + " — atrybuty zalezne od czesci klucza wydzielone." + ), fontsize=9, ) add_label( @@ -528,7 +572,7 @@ def draw_2nf() -> None: pad_inches=0.2, ) plt.close(fig) - print("Generated: nf_2nf_tables.png") + logger.info("Generated: nf_2nf_tables.png") # ============================================================ diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_pubsub_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_pubsub_diagrams.py index 3a0f2d8..e7c80e4 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_pubsub_diagrams.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_pubsub_diagrams.py @@ -12,18 +12,29 @@ 7. Exactly-once. All: A4-width, B&W, 300 DPI, laser-printer-friendly. -One diagram per image — no cramming. +One diagram per image -- no cramming. """ +from __future__ import annotations + +from dataclasses import dataclass +import logging +from pathlib import Path +from typing import TYPE_CHECKING + import matplotlib as mpl mpl.use("Agg") -from pathlib import Path import matplotlib.patches as mpatches from matplotlib.patches import FancyBboxPatch import matplotlib.pyplot as plt +if TYPE_CHECKING: + from matplotlib.axes import Axes + +logger = logging.getLogger(__name__) + DPI = 300 BG = "white" LN = "black" @@ -40,108 +51,207 @@ GRAY4 = "#F5F5F5" GRAY5 = "#C0C0C0" +@dataclass(frozen=True) +class BoxStyle: + """Optional styling for boxes.""" + + fill: str = "white" + lw: float = 1.2 + fontsize: float = FS + fontweight: str = "normal" + ha: str = "center" + va: str = "center" + rounded: bool = True + + +@dataclass(frozen=True) +class ArrowCfg: + """Config for arrows.""" + + lw: float = 1.2 + style: str = "->" + color: str = LN + label: str = "" + label_offset: float = 0.15 + label_fs: float = 8 + + +@dataclass(frozen=True) +class DashedCfg: + """Config for dashed arrows.""" + + lw: float = 1.0 + color: str = LN + label: str = "" + label_offset: float = 0.15 + label_fs: float = 8 + + def draw_box( - ax, - x, - y, - w, - h, - text, - fill="white", - lw=1.2, - fontsize=FS, - fontweight="normal", - ha="center", - va="center", - rounded=True, + ax: Axes, + pos: tuple[float, float], + size: tuple[float, float], + text: str, + style: BoxStyle | None = None, ) -> None: """Draw box.""" - if rounded: + s = style or BoxStyle() + x, y = pos + w, h = size + if s.rounded: rect = FancyBboxPatch( - (x, y), w, h, boxstyle="round,pad=0.05", lw=lw, edgecolor=LN, facecolor=fill + (x, y), + w, + h, + boxstyle="round,pad=0.05", + lw=s.lw, + edgecolor=LN, + facecolor=s.fill, ) else: - rect = mpatches.Rectangle((x, y), w, h, lw=lw, edgecolor=LN, facecolor=fill) + rect = mpatches.Rectangle( + (x, y), + w, + h, + lw=s.lw, + edgecolor=LN, + facecolor=s.fill, + ) ax.add_patch(rect) ax.text( x + w / 2, y + h / 2, text, - ha=ha, - va=va, - fontsize=fontsize, - fontweight=fontweight, + ha=s.ha, + va=s.va, + fontsize=s.fontsize, + fontweight=s.fontweight, wrap=True, ) def draw_arrow( - ax, - x1, - y1, - x2, - y2, - lw=1.2, - style="->", - color=LN, - label="", - label_offset=0.15, - label_fs=8, + ax: Axes, + start: tuple[float, float], + end: tuple[float, float], + cfg: ArrowCfg | None = None, ) -> None: """Draw arrow.""" + c = cfg or ArrowCfg() ax.annotate( "", - xy=(x2, y2), - xytext=(x1, y1), - arrowprops={"arrowstyle": style, "color": color, "lw": lw}, + xy=end, + xytext=start, + arrowprops={ + "arrowstyle": c.style, + "color": c.color, + "lw": c.lw, + }, ) - if label: - mx, my = (x1 + x2) / 2, (y1 + y2) / 2 + label_offset - ax.text(mx, my, label, ha="center", va="bottom", fontsize=label_fs, color=color) + if c.label: + mx = (start[0] + end[0]) / 2 + my = (start[1] + end[1]) / 2 + c.label_offset + ax.text( + mx, + my, + c.label, + ha="center", + va="bottom", + fontsize=c.label_fs, + color=c.color, + ) def draw_dashed_arrow( - ax, x1, y1, x2, y2, lw=1.0, color=LN, label="", label_offset=0.15, label_fs=8 + ax: Axes, + start: tuple[float, float], + end: tuple[float, float], + cfg: DashedCfg | None = None, ) -> None: """Draw dashed arrow.""" + c = cfg or DashedCfg() ax.annotate( "", - xy=(x2, y2), - xytext=(x1, y1), + xy=end, + xytext=start, arrowprops={ "arrowstyle": "->", - "color": color, - "lw": lw, + "color": c.color, + "lw": c.lw, "linestyle": "dashed", }, ) - if label: - mx, my = (x1 + x2) / 2, (y1 + y2) / 2 + label_offset - ax.text(mx, my, label, ha="center", va="bottom", fontsize=label_fs, color=color) + if c.label: + mx = (start[0] + end[0]) / 2 + my = (start[1] + end[1]) / 2 + c.label_offset + ax.text( + mx, + my, + c.label, + ha="center", + va="bottom", + fontsize=c.label_fs, + color=c.color, + ) -def draw_cross(ax, x, y, size=0.15, lw=2.5, color="black") -> None: +def draw_cross( + ax: Axes, + pos: tuple[float, float], + size: float = 0.15, + lw: float = 2.5, + color: str = "black", +) -> None: """Draw cross.""" - ax.plot([x - size, x + size], [y - size, y + size], color=color, lw=lw) - ax.plot([x - size, x + size], [y + size, y - size], color=color, lw=lw) - - -def draw_check(ax, x, y, size=0.15, lw=2.5, color="black") -> None: - """Draw check.""" - ax.plot([x - size, x - size * 0.2], [y, y - size * 0.7], color=color, lw=lw) + x, y = pos ax.plot( - [x - size * 0.2, x + size], [y - size * 0.7, y + size * 0.5], color=color, lw=lw + [x - size, x + size], + [y - size, y + size], + color=color, + lw=lw, + ) + ax.plot( + [x - size, x + size], + [y + size, y - size], + color=color, + lw=lw, ) -def save(fig, name) -> None: +def draw_check( + ax: Axes, + pos: tuple[float, float], + size: float = 0.15, + lw: float = 2.5, + color: str = "black", +) -> None: + """Draw check.""" + x, y = pos + ax.plot( + [x - size, x - size * 0.2], + [y, y - size * 0.7], + color=color, + lw=lw, + ) + ax.plot( + [x - size * 0.2, x + size], + [y - size * 0.7, y + size * 0.5], + color=color, + lw=lw, + ) + + +def save(fig: plt.Figure, name: str) -> None: """Save.""" plt.tight_layout() fig.savefig( - str(Path(OUTPUT_DIR) / name), dpi=DPI, bbox_inches="tight", facecolor=BG + str(Path(OUTPUT_DIR) / name), + dpi=DPI, + bbox_inches="tight", + facecolor=BG, ) plt.close(fig) - print(f" ✓ {name}") + logger.info(" \u2713 %s", name) # ============================================================ @@ -155,84 +265,107 @@ def draw_sub_topic() -> None: ax.set_aspect("equal") ax.axis("off") ax.set_title( - "Subskrypcja topic-based — routing po nazwie tematu", + "Subskrypcja topic-based" + " \u2014 routing po nazwie tematu", fontsize=FS_TITLE, fontweight="bold", pad=12, ) - # Publisher + messages - draw_box( - ax, 0.2, 3.2, 2.4, 1.1, "Publisher", fill=GRAY1, fontsize=10, fontweight="bold" + bold10 = BoxStyle( + fill=GRAY1, fontsize=10, fontweight="bold" ) - draw_box(ax, 0.3, 1.8, 2.2, 0.8, 'topic: "orders"', fill=GRAY4, fontsize=8) - draw_box(ax, 0.3, 0.7, 2.2, 0.8, 'topic: "payments"', fill=GRAY4, fontsize=8) + fs85 = BoxStyle(fill=GRAY1, fontsize=8.5) - # Broker + draw_box( + ax, (0.2, 3.2), (2.4, 1.1), "Publisher", bold10 + ) draw_box( ax, - 4.2, - 1.5, - 2.8, - 2.2, + (0.3, 1.8), + (2.2, 0.8), + 'topic: "orders"', + BoxStyle(fill=GRAY4, fontsize=8), + ) + draw_box( + ax, + (0.3, 0.7), + (2.2, 0.8), + 'topic: "payments"', + BoxStyle(fill=GRAY4, fontsize=8), + ) + + draw_box( + ax, + (4.2, 1.5), + (2.8, 2.2), "BROKER\n\ntopic routing", - fill=GRAY2, - fontsize=10, - fontweight="bold", + BoxStyle( + fill=GRAY2, fontsize=10, fontweight="bold" + ), ) - # Subscribers draw_box( ax, - 8.5, - 3.8, - 3.0, - 1.0, + (8.5, 3.8), + (3.0, 1.0), 'Subscriber A\nsubskrybuje: "orders"', - fill=GRAY1, - fontsize=8.5, + fs85, ) draw_box( ax, - 8.5, - 2.2, - 3.0, - 1.0, + (8.5, 2.2), + (3.0, 1.0), 'Subscriber B\nsubskrybuje: "payments"', - fill=GRAY1, - fontsize=8.5, + fs85, ) draw_box( ax, - 8.5, - 0.6, - 3.0, - 1.0, + (8.5, 0.6), + (3.0, 1.0), 'Subscriber C\nsubskrybuje: "orders"', - fill=GRAY1, - fontsize=8.5, + fs85, ) - # Arrows: publisher → broker - draw_arrow(ax, 2.6, 2.2, 4.2, 2.8, label_fs=8) - draw_arrow(ax, 2.6, 1.1, 4.2, 2.2, label_fs=8) + fs8 = ArrowCfg(label_fs=8) + draw_arrow(ax, (2.6, 2.2), (4.2, 2.8), fs8) + draw_arrow(ax, (2.6, 1.1), (4.2, 2.2), fs8) - # Arrows: broker → subscribers - draw_arrow(ax, 7.0, 3.4, 8.5, 4.2, label='"orders"', label_fs=8) - draw_arrow(ax, 7.0, 2.6, 8.5, 2.7, label='"payments"', label_fs=8) - draw_arrow(ax, 7.0, 2.2, 8.5, 1.2, label='"orders"', label_fs=8) + draw_arrow( + ax, + (7.0, 3.4), + (8.5, 4.2), + ArrowCfg(label='"orders"', label_fs=8), + ) + draw_arrow( + ax, + (7.0, 2.6), + (8.5, 2.7), + ArrowCfg(label='"payments"', label_fs=8), + ) + draw_arrow( + ax, + (7.0, 2.2), + (8.5, 1.2), + ArrowCfg(label='"orders"', label_fs=8), + ) - # Explanation ax.text( 6.0, 0.1, - "Subscriber deklaruje nazwę tematu. Broker kieruje wiadomości\n" - "do WSZYSTKICH subscriberów danego tematu. Najprostszy model.", + "Subscriber deklaruje nazw\u0119 tematu." + " Broker kieruje wiadomo\u015bci\n" + "do WSZYSTKICH subscriber\u00f3w" + " danego tematu. Najprostszy model.", ha="center", va="bottom", fontsize=8.5, style="italic", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": GRAY4, + "edgecolor": GRAY3, + }, ) save(fig, "pubsub_sub_topic.png") @@ -249,69 +382,107 @@ def draw_sub_content() -> None: ax.set_aspect("equal") ax.axis("off") ax.set_title( - "Subskrypcja content-based — filtrowanie po treści wiadomości", + "Subskrypcja content-based" + " \u2014 filtrowanie po tre\u015bci wiadomo\u015bci", fontsize=FS_TITLE, fontweight="bold", pad=12, ) - # Publisher + message + bold10 = BoxStyle( + fill=GRAY1, fontsize=10, fontweight="bold" + ) draw_box( - ax, 0.2, 3.5, 2.4, 1.1, "Publisher", fill=GRAY1, fontsize=10, fontweight="bold" + ax, (0.2, 3.5), (2.4, 1.1), "Publisher", bold10 ) draw_box( ax, - 0.2, - 1.8, - 2.4, - 1.2, + (0.2, 1.8), + (2.4, 1.2), 'price: 150\ntype: "book"\ncategory: "IT"', - fill=GRAY4, - fontsize=8.5, + BoxStyle(fill=GRAY4, fontsize=8.5), ) - # Broker draw_box( ax, - 4.0, - 2.0, - 3.0, - 2.5, - "BROKER\n\newaluuje filtry\nkażdego subscribera", - fill=GRAY2, - fontsize=9, - fontweight="bold", + (4.0, 2.0), + (3.0, 2.5), + "BROKER\n\newaluuje filtry\n" + "ka\u017cdego subscribera", + BoxStyle( + fill=GRAY2, fontsize=9, fontweight="bold" + ), ) - # Subscribers with filters + fs9 = BoxStyle(fill=GRAY1, fontsize=9) draw_box( - ax, 8.5, 4.2, 3.2, 1.0, "Sub A\nfiltr: price > 100", fill=GRAY1, fontsize=9 + ax, + (8.5, 4.2), + (3.2, 1.0), + "Sub A\nfiltr: price > 100", + fs9, ) draw_box( - ax, 8.5, 2.6, 3.2, 1.0, 'Sub B\nfiltr: type = "food"', fill=GRAY1, fontsize=9 + ax, + (8.5, 2.6), + (3.2, 1.0), + 'Sub B\nfiltr: type = "food"', + fs9, + ) + draw_box( + ax, + (8.5, 1.0), + (3.2, 1.0), + "Sub C\nfiltr: price < 50", + fs9, ) - draw_box(ax, 8.5, 1.0, 3.2, 1.0, "Sub C\nfiltr: price < 50", fill=GRAY1, fontsize=9) - # Arrows - draw_arrow(ax, 2.6, 2.4, 4.0, 3.0) - draw_arrow(ax, 7.0, 4.0, 8.5, 4.6, label="150 > 100 ✓ dostarczono", label_fs=8) - draw_dashed_arrow( - ax, 7.0, 3.2, 8.5, 3.1, label='"book" ≠ "food" ✗ odrzucono', label_fs=8 + draw_arrow(ax, (2.6, 2.4), (4.0, 3.0)) + draw_arrow( + ax, + (7.0, 4.0), + (8.5, 4.6), + ArrowCfg( + label="150 > 100 \u2713 dostarczono", + label_fs=8, + ), ) draw_dashed_arrow( - ax, 7.0, 2.5, 8.5, 1.6, label="150 < 50 ✗ odrzucono", label_fs=8 + ax, + (7.0, 3.2), + (8.5, 3.1), + DashedCfg( + label='"book" \u2260 "food"' + " \u2717 odrzucono", + label_fs=8, + ), + ) + draw_dashed_arrow( + ax, + (7.0, 2.5), + (8.5, 1.6), + DashedCfg( + label="150 < 50 \u2717 odrzucono", + label_fs=8, + ), ) ax.text( 6.0, 0.2, - "Broker analizuje TREŚĆ wiadomości i ewaluuje predykaty.\n" - "Bardziej elastyczny niż topic-based, ale wolniejszy (koszt ewaluacji).", + "Broker analizuje TRE\u015a\u0106 wiadomo\u015bci" + " i ewaluuje predykaty.\n" + "Bardziej elastyczny ni\u017c topic-based," + " ale wolniejszy (koszt ewaluacji).", ha="center", va="bottom", fontsize=8.5, style="italic", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": GRAY4, + "edgecolor": GRAY3, + }, ) save(fig, "pubsub_sub_content.png") @@ -328,95 +499,153 @@ def draw_sub_type() -> None: ax.set_aspect("equal") ax.axis("off") ax.set_title( - "Subskrypcja type-based — routing po typie (klasie) obiektu", + "Subskrypcja type-based" + " \u2014 routing po typie (klasie) obiektu", fontsize=FS_TITLE, fontweight="bold", pad=12, ) - # Publisher + bold10 = BoxStyle( + fill=GRAY1, fontsize=10, fontweight="bold" + ) draw_box( - ax, 0.2, 4.2, 2.4, 1.1, "Publisher", fill=GRAY1, fontsize=10, fontweight="bold" + ax, (0.2, 4.2), (2.4, 1.1), "Publisher", bold10 ) - # Messages - draw_box(ax, 0.1, 2.8, 2.6, 0.9, "new OrderEvent()", fill=GRAY4, fontsize=9) - draw_box(ax, 0.1, 1.5, 2.6, 0.9, "new PaymentEvent()", fill=GRAY4, fontsize=9) - - # Broker + fs9_g4 = BoxStyle(fill=GRAY4, fontsize=9) draw_box( ax, - 4.0, - 2.3, - 3.0, - 2.4, - "BROKER\n\nrouting po\ntypie klasy", - fill=GRAY2, - fontsize=10, - fontweight="bold", + (0.1, 2.8), + (2.6, 0.9), + "new OrderEvent()", + fs9_g4, + ) + draw_box( + ax, + (0.1, 1.5), + (2.6, 0.9), + "new PaymentEvent()", + fs9_g4, ) - # Subscribers - draw_box(ax, 8.5, 4.8, 3.2, 1.0, "Sub A\n→ OrderEvent", fill=GRAY1, fontsize=9) - draw_box(ax, 8.5, 3.2, 3.2, 1.0, "Sub B\n→ PaymentEvent", fill=GRAY1, fontsize=9) - draw_box(ax, 8.5, 1.6, 3.2, 1.0, "Sub C\n→ Event (base)", fill=GRAY1, fontsize=9) + draw_box( + ax, + (4.0, 2.3), + (3.0, 2.4), + "BROKER\n\nrouting po\ntypie klasy", + BoxStyle( + fill=GRAY2, fontsize=10, fontweight="bold" + ), + ) - # Arrows - draw_arrow(ax, 2.7, 3.2, 4.0, 3.8) - draw_arrow(ax, 2.7, 2.0, 4.0, 3.0) - draw_arrow(ax, 7.0, 4.3, 8.5, 5.2, label="OrderEvent", label_fs=8) - draw_arrow(ax, 7.0, 3.5, 8.5, 3.7, label="PaymentEvent", label_fs=8) - draw_arrow(ax, 7.0, 3.0, 8.5, 2.2, label="oba (dziedziczenie!)", label_fs=8) + fs9 = BoxStyle(fill=GRAY1, fontsize=9) + draw_box( + ax, + (8.5, 4.8), + (3.2, 1.0), + "Sub A\n\u2192 OrderEvent", + fs9, + ) + draw_box( + ax, + (8.5, 3.2), + (3.2, 1.0), + "Sub B\n\u2192 PaymentEvent", + fs9, + ) + draw_box( + ax, + (8.5, 1.6), + (3.2, 1.0), + "Sub C\n\u2192 Event (base)", + fs9, + ) + + draw_arrow(ax, (2.7, 3.2), (4.0, 3.8)) + draw_arrow(ax, (2.7, 2.0), (4.0, 3.0)) + draw_arrow( + ax, + (7.0, 4.3), + (8.5, 5.2), + ArrowCfg(label="OrderEvent", label_fs=8), + ) + draw_arrow( + ax, + (7.0, 3.5), + (8.5, 3.7), + ArrowCfg(label="PaymentEvent", label_fs=8), + ) + draw_arrow( + ax, + (7.0, 3.0), + (8.5, 2.2), + ArrowCfg( + label="oba (dziedziczenie!)", label_fs=8 + ), + ) - # Class hierarchy inset hx, hy = 0.5, 0.0 draw_box( ax, - hx + 2.0, - hy + 0.2, - 1.8, - 0.6, + (hx + 2.0, hy + 0.2), + (1.8, 0.6), "Event", - fill=GRAY3, - fontsize=8, - fontweight="bold", + BoxStyle( + fill=GRAY3, fontsize=8, fontweight="bold" + ), ) - draw_box(ax, hx + 0.0, hy + 0.2, 1.8, 0.6, "OrderEvent", fill=GRAY4, fontsize=7.5) - draw_box(ax, hx + 4.0, hy + 0.2, 2.0, 0.6, "PaymentEvent", fill=GRAY4, fontsize=7.5) - draw_arrow( + draw_box( ax, - hx + 2.9, - hy + 0.2, - hx + 0.9, - hy + 0.2, - lw=1.0, - style="->", - label="extends", - label_offset=-0.3, - label_fs=7, + (hx, hy + 0.2), + (1.8, 0.6), + "OrderEvent", + BoxStyle(fill=GRAY4, fontsize=7.5), + ) + draw_box( + ax, + (hx + 4.0, hy + 0.2), + (2.0, 0.6), + "PaymentEvent", + BoxStyle(fill=GRAY4, fontsize=7.5), ) draw_arrow( ax, - hx + 2.9, - hy + 0.2, - hx + 5.0, - hy + 0.2, - lw=1.0, - style="->", - label="extends", - label_offset=-0.3, - label_fs=7, + (hx + 2.9, hy + 0.2), + (hx + 0.9, hy + 0.2), + ArrowCfg( + lw=1.0, + label="extends", + label_offset=-0.3, + label_fs=7, + ), + ) + draw_arrow( + ax, + (hx + 2.9, hy + 0.2), + (hx + 5.0, hy + 0.2), + ArrowCfg( + lw=1.0, + label="extends", + label_offset=-0.3, + label_fs=7, + ), ) ax.text( 9.5, 0.5, - "Sub C subskrybuje bazowy Event\n→ otrzymuje WSZYSTKIE podtypy", + "Sub C subskrybuje bazowy Event\n" + "\u2192 otrzymuje WSZYSTKIE podtypy", ha="center", va="center", fontsize=8.5, style="italic", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": GRAY4, + "edgecolor": GRAY3, + }, ) save(fig, "pubsub_sub_type.png") @@ -433,34 +662,58 @@ def draw_sub_hierarchical() -> None: ax.set_aspect("equal") ax.axis("off") ax.set_title( - "Subskrypcja hierarchiczna (wildcards) — wzorce tematów", + "Subskrypcja hierarchiczna (wildcards)" + " \u2014 wzorce temat\u00f3w", fontsize=FS_TITLE, fontweight="bold", pad=12, ) - # Topic tree + bold10 = BoxStyle( + fill=GRAY2, fontsize=10, fontweight="bold" + ) draw_box( - ax, 4.5, 5.8, 2.4, 0.8, "sensors/", fill=GRAY2, fontsize=10, fontweight="bold" + ax, (4.5, 5.8), (2.4, 0.8), "sensors/", bold10 ) - draw_box(ax, 1.5, 4.2, 2.4, 0.8, "temperature/", fill=GRAY3, fontsize=9) - draw_box(ax, 7.5, 4.2, 2.4, 0.8, "humidity/", fill=GRAY3, fontsize=9) + fs9_g3 = BoxStyle(fill=GRAY3, fontsize=9) + draw_box( + ax, + (1.5, 4.2), + (2.4, 0.8), + "temperature/", + fs9_g3, + ) + draw_box( + ax, + (7.5, 4.2), + (2.4, 0.8), + "humidity/", + fs9_g3, + ) - draw_box(ax, 0.2, 2.8, 1.8, 0.7, "room1", fill=GRAY4, fontsize=8.5) - draw_box(ax, 2.4, 2.8, 1.8, 0.7, "room2", fill=GRAY4, fontsize=8.5) - draw_box(ax, 6.8, 2.8, 1.8, 0.7, "room1", fill=GRAY4, fontsize=8.5) - draw_box(ax, 9.0, 2.8, 1.8, 0.7, "room2", fill=GRAY4, fontsize=8.5) + fs85_g4 = BoxStyle(fill=GRAY4, fontsize=8.5) + draw_box( + ax, (0.2, 2.8), (1.8, 0.7), "room1", fs85_g4 + ) + draw_box( + ax, (2.4, 2.8), (1.8, 0.7), "room2", fs85_g4 + ) + draw_box( + ax, (6.8, 2.8), (1.8, 0.7), "room1", fs85_g4 + ) + draw_box( + ax, (9.0, 2.8), (1.8, 0.7), "room2", fs85_g4 + ) - # Tree edges - draw_arrow(ax, 5.7, 5.8, 2.7, 5.0, lw=1.0) - draw_arrow(ax, 5.7, 5.8, 8.7, 5.0, lw=1.0) - draw_arrow(ax, 2.2, 4.2, 1.1, 3.5, lw=1.0) - draw_arrow(ax, 3.2, 4.2, 3.3, 3.5, lw=1.0) - draw_arrow(ax, 8.2, 4.2, 7.7, 3.5, lw=1.0) - draw_arrow(ax, 9.2, 4.2, 9.9, 3.5, lw=1.0) + thin = ArrowCfg(lw=1.0) + draw_arrow(ax, (5.7, 5.8), (2.7, 5.0), thin) + draw_arrow(ax, (5.7, 5.8), (8.7, 5.0), thin) + draw_arrow(ax, (2.2, 4.2), (1.1, 3.5), thin) + draw_arrow(ax, (3.2, 4.2), (3.3, 3.5), thin) + draw_arrow(ax, (8.2, 4.2), (7.7, 3.5), thin) + draw_arrow(ax, (9.2, 4.2), (9.9, 3.5), thin) - # Full paths ax.text( 1.1, 2.4, @@ -480,21 +733,47 @@ def draw_sub_hierarchical() -> None: style="italic", ) - # Wildcard examples ax.text( - 0.3, 1.5, "Wzorce subskrypcji (MQTT-style):", fontsize=10, fontweight="bold" + 0.3, + 1.5, + "Wzorce subskrypcji (MQTT-style):", + fontsize=10, + fontweight="bold", ) patterns = [ - ('"sensors/temperature/room1"', "→ TYLKO room1", "(dokładne dopasowanie)"), - ('"sensors/temperature/*"', "→ room1, room2", "( * = jeden poziom)"), - ('"sensors/#"', "→ WSZYSTKO", "( # = dowolna głębokość)"), + ( + '"sensors/temperature/room1"', + "\u2192 TYLKO room1", + "(dok\u0142adne dopasowanie)", + ), + ( + '"sensors/temperature/*"', + "\u2192 room1, room2", + "( * = jeden poziom)", + ), + ( + '"sensors/#"', + "\u2192 WSZYSTKO", + "( # = dowolna g\u0142\u0119boko\u015b\u0107)", + ), ] for i, (pat, result, note) in enumerate(patterns): yy = 0.9 - i * 0.55 - ax.text(0.5, yy, pat, fontsize=9, fontweight="bold", fontfamily="monospace") - ax.text(7.0, yy, result, fontsize=9, fontweight="bold") - ax.text(9.5, yy, note, fontsize=8, style="italic") + ax.text( + 0.5, + yy, + pat, + fontsize=9, + fontweight="bold", + fontfamily="monospace", + ) + ax.text( + 7.0, yy, result, fontsize=9, fontweight="bold" + ) + ax.text( + 9.5, yy, note, fontsize=8, style="italic" + ) save(fig, "pubsub_sub_hierarchical.png") @@ -510,54 +789,119 @@ def draw_qos_at_most_once() -> None: ax.set_aspect("equal") ax.axis("off") ax.set_title( - 'QoS: At-most-once — „wyślij i zapomnij" (0 lub 1 dostarczenie)', + "QoS: At-most-once" + " \u2014 \u201ewy\u015blij i zapomnij\u201d" + " (0 lub 1 dostarczenie)", fontsize=FS_TITLE, fontweight="bold", pad=12, ) - # Actors px, bx, sx = 1.0, 4.8, 8.5 pw, bw, sw = 2.0, 2.2, 2.0 bh = 0.8 - draw_box( - ax, px, 5.0, pw, bh, "Publisher", fill=GRAY1, fontsize=10, fontweight="bold" + bold10_g1 = BoxStyle( + fill=GRAY1, fontsize=10, fontweight="bold" + ) + bold10_g2 = BoxStyle( + fill=GRAY2, fontsize=10, fontweight="bold" ) - draw_box(ax, bx, 5.0, bw, bh, "Broker", fill=GRAY2, fontsize=10, fontweight="bold") draw_box( - ax, sx, 5.0, sw, bh, "Subscriber", fill=GRAY1, fontsize=10, fontweight="bold" + ax, (px, 5.0), (pw, bh), "Publisher", bold10_g1 + ) + draw_box( + ax, (bx, 5.0), (bw, bh), "Broker", bold10_g2 + ) + draw_box( + ax, + (sx, 5.0), + (sw, bh), + "Subscriber", + bold10_g1, ) - # Timelines for xc in [px + pw / 2, bx + bw / 2, sx + sw / 2]: - ax.plot([xc, xc], [5.0, 1.2], color=GRAY3, lw=1, linestyle=":") + ax.plot( + [xc, xc], + [5.0, 1.2], + color=GRAY3, + lw=1, + linestyle=":", + ) # Scenario A: success y = 4.3 - ax.text(0.2, y + 0.15, "Scenariusz A:", fontsize=8.5, fontweight="bold") - draw_arrow(ax, px + pw / 2, y, bx + bw / 2, y, label="MSG", label_fs=9) - draw_arrow(ax, bx + bw / 2, y - 0.6, sx + sw / 2, y - 0.6, label="MSG", label_fs=9) - draw_check(ax, sx + sw / 2 + 0.4, y - 0.6, size=0.18) - ax.text(sx + sw / 2 + 0.7, y - 0.6, "OK", fontsize=9, fontweight="bold") + ax.text( + 0.2, + y + 0.15, + "Scenariusz A:", + fontsize=8.5, + fontweight="bold", + ) + msg9 = ArrowCfg(label="MSG", label_fs=9) + draw_arrow( + ax, (px + pw / 2, y), (bx + bw / 2, y), msg9 + ) + draw_arrow( + ax, + (bx + bw / 2, y - 0.6), + (sx + sw / 2, y - 0.6), + msg9, + ) + draw_check(ax, (sx + sw / 2 + 0.4, y - 0.6), size=0.18) + ax.text( + sx + sw / 2 + 0.7, + y - 0.6, + "OK", + fontsize=9, + fontweight="bold", + ) # Scenario B: lost y = 2.6 - ax.text(0.2, y + 0.15, "Scenariusz B:", fontsize=8.5, fontweight="bold") - draw_arrow(ax, px + pw / 2, y, bx + bw / 2, y, label="MSG", label_fs=9) - draw_dashed_arrow(ax, bx + bw / 2, y - 0.6, 7.5, y - 0.6) - draw_cross(ax, 7.8, y - 0.6, size=0.2) - ax.text(8.2, y - 0.55, "UTRACONA", fontsize=9, fontweight="bold") - ax.text(8.2, y - 1.0, "(brak retransmisji)", fontsize=8, style="italic") + ax.text( + 0.2, + y + 0.15, + "Scenariusz B:", + fontsize=8.5, + fontweight="bold", + ) + draw_arrow( + ax, (px + pw / 2, y), (bx + bw / 2, y), msg9 + ) + draw_dashed_arrow( + ax, (bx + bw / 2, y - 0.6), (7.5, y - 0.6) + ) + draw_cross(ax, (7.8, y - 0.6), size=0.2) + ax.text( + 8.2, + y - 0.55, + "UTRACONA", + fontsize=9, + fontweight="bold", + ) + ax.text( + 8.2, + y - 1.0, + "(brak retransmisji)", + fontsize=8, + style="italic", + ) - # Summary ax.text( 6.0, 0.5, - "Brak ACK, brak retransmisji. Najszybszy. Use case: logi, metryki, telemetria.", + "Brak ACK, brak retransmisji." + " Najszybszy. Use case:" + " logi, metryki, telemetria.", ha="center", va="center", fontsize=9, - bbox={"boxstyle": "round,pad=0.4", "facecolor": GRAY4, "edgecolor": GRAY3}, + bbox={ + "boxstyle": "round,pad=0.4", + "facecolor": GRAY4, + "edgecolor": GRAY3, + }, ) save(fig, "pubsub_qos_at_most_once.png") @@ -574,7 +918,9 @@ def draw_qos_at_least_once() -> None: ax.set_aspect("equal") ax.axis("off") ax.set_title( - 'QoS: At-least-once — „powtarzaj aż potwierdzą" (≥1 dostarczenie)', + "QoS: At-least-once" + " \u2014 \u201epowtarzaj a\u017c potwierdz\u0105\u201d" + " (\u22651 dostarczenie)", fontsize=FS_TITLE, fontweight="bold", pad=12, @@ -583,70 +929,142 @@ def draw_qos_at_least_once() -> None: bx, bw = 3.5, 2.2 sx, sw = 8.0, 2.2 bh = 0.8 - draw_box(ax, bx, 5.5, bw, bh, "Broker", fill=GRAY2, fontsize=10, fontweight="bold") draw_box( - ax, sx, 5.5, sw, bh, "Subscriber", fill=GRAY1, fontsize=10, fontweight="bold" + ax, + (bx, 5.5), + (bw, bh), + "Broker", + BoxStyle( + fill=GRAY2, fontsize=10, fontweight="bold" + ), + ) + draw_box( + ax, + (sx, 5.5), + (sw, bh), + "Subscriber", + BoxStyle( + fill=GRAY1, fontsize=10, fontweight="bold" + ), ) - # Timelines for xc in [bx + bw / 2, sx + sw / 2]: - ax.plot([xc, xc], [5.5, 0.8], color=GRAY3, lw=1, linestyle=":") + ax.plot( + [xc, xc], + [5.5, 0.8], + color=GRAY3, + lw=1, + linestyle=":", + ) # Step 1: send MSG y1 = 4.8 - draw_arrow(ax, bx + bw / 2, y1, sx + sw / 2, y1, label="MSG #1", label_fs=9) - draw_check(ax, sx + sw + 0.2, y1, size=0.15) + draw_arrow( + ax, + (bx + bw / 2, y1), + (sx + sw / 2, y1), + ArrowCfg(label="MSG #1", label_fs=9), + ) + draw_check(ax, (sx + sw + 0.2, y1), size=0.15) ax.text(sx + sw + 0.5, y1, "odebrano", fontsize=8) # Step 2: ACK lost y2 = 3.9 - draw_dashed_arrow(ax, sx + sw / 2, y2, bx + bw + 1.2, y2) - ax.text((bx + bw / 2 + sx + sw / 2) / 2, y2 + 0.18, "ACK", fontsize=9) - draw_cross(ax, bx + bw + 0.8, y2, size=0.18) - ax.text(bx + 0.3, y2 - 0.35, "ACK utracony!", fontsize=8.5, style="italic") + draw_dashed_arrow( + ax, + (sx + sw / 2, y2), + (bx + bw + 1.2, y2), + ) + ax.text( + (bx + bw / 2 + sx + sw / 2) / 2, + y2 + 0.18, + "ACK", + fontsize=9, + ) + draw_cross(ax, (bx + bw + 0.8, y2), size=0.18) + ax.text( + bx + 0.3, + y2 - 0.35, + "ACK utracony!", + fontsize=8.5, + style="italic", + ) - # Step 3: timeout → retry + # Step 3: timeout -> retry y3 = 2.9 ax.text( - bx + bw / 2, y3 + 0.45, "timeout...", fontsize=8.5, style="italic", ha="center" + bx + bw / 2, + y3 + 0.45, + "timeout...", + fontsize=8.5, + style="italic", + ha="center", + ) + draw_arrow( + ax, + (bx + bw / 2, y3), + (sx + sw / 2, y3), + ArrowCfg(label="MSG #1 (retry)", label_fs=9), + ) + draw_check(ax, (sx + sw + 0.2, y3), size=0.15) + ax.text( + sx + sw + 0.5, + y3, + "odebrano\n(ponownie!)", + fontsize=8, ) - draw_arrow(ax, bx + bw / 2, y3, sx + sw / 2, y3, label="MSG #1 (retry)", label_fs=9) - draw_check(ax, sx + sw + 0.2, y3, size=0.15) - ax.text(sx + sw + 0.5, y3, "odebrano\n(ponownie!)", fontsize=8) # Step 4: ACK ok y4 = 2.0 - draw_arrow(ax, sx + sw / 2, y4, bx + bw / 2, y4, label="ACK", label_fs=9) - draw_check(ax, bx + bw / 2 - 0.5, y4, size=0.18) + draw_arrow( + ax, + (sx + sw / 2, y4), + (bx + bw / 2, y4), + ArrowCfg(label="ACK", label_fs=9), + ) + draw_check(ax, (bx + bw / 2 - 0.5, y4), size=0.18) # Duplicate bracket ax.annotate( "", xy=(sx + sw + 1.3, y1), xytext=(sx + sw + 1.3, y3), - arrowprops={"arrowstyle": "<->", "color": "black", "lw": 1.2}, + arrowprops={ + "arrowstyle": "<->", + "color": "black", + "lw": 1.2, + }, ) ax.text( sx + sw + 1.6, (y1 + y3) / 2, - "DUPLIKAT!\nSubscriber\notrzymał 2x", + "DUPLIKAT!\nSubscriber\notrzyma\u0142 2x", fontsize=9, ha="left", va="center", fontweight="bold", - bbox={"boxstyle": "round,pad=0.25", "facecolor": GRAY4, "edgecolor": GRAY3}, + bbox={ + "boxstyle": "round,pad=0.25", + "facecolor": GRAY4, + "edgecolor": GRAY3, + }, ) - # Summary ax.text( 6.0, 0.5, - "Broker czeka na ACK, retransmituje po timeout. Mogą być duplikaty!\n" - "Use case: zamówienia, płatności (subscriber musi być idempotentny).", + "Broker czeka na ACK, retransmituje" + " po timeout. Mog\u0105 by\u0107 duplikaty!\n" + "Use case: zam\u00f3wienia, p\u0142atno\u015bci" + " (subscriber musi by\u0107 idempotentny).", ha="center", va="center", fontsize=9, - bbox={"boxstyle": "round,pad=0.4", "facecolor": GRAY4, "edgecolor": GRAY3}, + bbox={ + "boxstyle": "round,pad=0.4", + "facecolor": GRAY4, + "edgecolor": GRAY3, + }, ) save(fig, "pubsub_qos_at_least_once.png") @@ -663,7 +1081,8 @@ def draw_qos_exactly_once() -> None: ax.set_aspect("equal") ax.axis("off") ax.set_title( - "QoS: Exactly-once — 4-krokowy handshake (dokładnie 1 dostarczenie)", + "QoS: Exactly-once \u2014 4-krokowy" + " handshake (dok\u0142adnie 1 dostarczenie)", fontsize=FS_TITLE, fontweight="bold", pad=12, @@ -672,30 +1091,65 @@ def draw_qos_exactly_once() -> None: bx, bw = 2.5, 2.2 sx, sw = 7.5, 2.2 bh = 0.8 - draw_box(ax, bx, 6.0, bw, bh, "Broker", fill=GRAY2, fontsize=10, fontweight="bold") draw_box( - ax, sx, 6.0, sw, bh, "Subscriber", fill=GRAY1, fontsize=10, fontweight="bold" + ax, + (bx, 6.0), + (bw, bh), + "Broker", + BoxStyle( + fill=GRAY2, fontsize=10, fontweight="bold" + ), + ) + draw_box( + ax, + (sx, 6.0), + (sw, bh), + "Subscriber", + BoxStyle( + fill=GRAY1, fontsize=10, fontweight="bold" + ), ) - # Timelines for xc in [bx + bw / 2, sx + sw / 2]: - ax.plot([xc, xc], [6.0, 1.0], color=GRAY3, lw=1, linestyle=":") + ax.plot( + [xc, xc], + [6.0, 1.0], + color=GRAY3, + lw=1, + linestyle=":", + ) - # 4-step handshake steps = [ - (5.2, "right", "PUBLISH (msg_id=42)", "Broker wysyła wiadomość"), + ( + 5.2, + "right", + "PUBLISH (msg_id=42)", + "Broker wysy\u0142a wiadomo\u015b\u0107", + ), ( 4.2, "left", - "PUBREC (otrzymałem id=42)", - "Sub potwierdza odbiór, zapisuje id", + "PUBREC (otrzyma\u0142em id=42)", + "Sub potwierdza odbi\u00f3r," + " zapisuje id", + ), + ( + 3.2, + "right", + "PUBREL (mo\u017cesz przetworzy\u0107)", + "Broker zwalnia wiadomo\u015b\u0107", + ), + ( + 2.2, + "left", + "PUBCOMP (zako\u0144czone)", + "Sub potwierdza przetworzenie", ), - (3.2, "right", "PUBREL (możesz przetworzyć)", "Broker zwalnia wiadomość"), - (2.2, "left", "PUBCOMP (zakończone)", "Sub potwierdza przetworzenie"), ] - for i, (y, direction, label, desc) in enumerate(steps): - # Step number + for i, (y, direction, label, desc) in enumerate( + steps + ): ax.text( bx + bw / 2 - 0.7, y, @@ -704,29 +1158,54 @@ def draw_qos_exactly_once() -> None: fontweight="bold", ha="center", va="center", - bbox={"boxstyle": "circle,pad=0.18", "facecolor": GRAY3, "edgecolor": LN}, + bbox={ + "boxstyle": "circle,pad=0.18", + "facecolor": GRAY3, + "edgecolor": LN, + }, ) if direction == "right": - draw_arrow(ax, bx + bw / 2, y, sx + sw / 2, y, label=label, label_fs=9) + draw_arrow( + ax, + (bx + bw / 2, y), + (sx + sw / 2, y), + ArrowCfg(label=label, label_fs=9), + ) else: - draw_arrow(ax, sx + sw / 2, y, bx + bw / 2, y, label=label, label_fs=9) + draw_arrow( + ax, + (sx + sw / 2, y), + (bx + bw / 2, y), + ArrowCfg(label=label, label_fs=9), + ) - # Side description ax.text( - sx + sw + 0.3, y, desc, fontsize=8, ha="left", va="center", style="italic" + sx + sw + 0.3, + y, + desc, + fontsize=8, + ha="left", + va="center", + style="italic", ) - # Summary ax.text( 6.0, 0.6, - "Deduplikacja po msg_id. Sub nie przetwarza przed PUBREL.\n" - "Najkosztowniejszy (4 pakiety). Use case: transakcje finansowe, krytyczne zdarzenia.", + "Deduplikacja po msg_id." + " Sub nie przetwarza przed PUBREL.\n" + "Najkosztowniejszy (4 pakiety)." + " Use case: transakcje finansowe," + " krytyczne zdarzenia.", ha="center", va="center", fontsize=9, - bbox={"boxstyle": "round,pad=0.4", "facecolor": GRAY4, "edgecolor": GRAY3}, + bbox={ + "boxstyle": "round,pad=0.4", + "facecolor": GRAY4, + "edgecolor": GRAY3, + }, ) save(fig, "pubsub_qos_exactly_once.png") @@ -736,7 +1215,10 @@ def draw_qos_exactly_once() -> None: # Main # ============================================================ if __name__ == "__main__": - print("Generating Pub/Sub diagrams (7 separate images)...") + logger.info( + "Generating Pub/Sub diagrams" + " (7 separate images)..." + ) draw_sub_topic() draw_sub_content() draw_sub_type() @@ -744,4 +1226,4 @@ if __name__ == "__main__": draw_qos_at_most_once() draw_qos_at_least_once() draw_qos_exactly_once() - print("Done!") + logger.info("Done!")