diff --git a/plots/bode-basic/implementations/plotnine.py b/plots/bode-basic/implementations/plotnine.py new file mode 100644 index 0000000000..9c9a9d7aef --- /dev/null +++ b/plots/bode-basic/implementations/plotnine.py @@ -0,0 +1,199 @@ +""" pyplots.ai +bode-basic: Bode Plot for Frequency Response +Library: plotnine 0.15.3 | Python 3.14.3 +Quality: 81/100 | Created: 2026-03-21 +""" + +import numpy as np +import pandas as pd +from plotnine import ( + aes, + element_line, + element_rect, + element_text, + facet_wrap, + geom_hline, + geom_line, + geom_point, + geom_segment, + geom_text, + geom_vline, + ggplot, + labs, + scale_x_log10, + scale_y_continuous, + theme, + theme_minimal, +) + + +# Data - Third-order open-loop transfer function: +# G(s) = 5 / [(s+1)(0.5s+1)(0.2s+1)] +# Poles at s = -1, -2, -5 — stable system with clear gain and phase margins +frequency_hz = np.logspace(-1.5, 1.5, 600) +omega = 2 * np.pi * frequency_hz +jw = 1j * omega +G = 5.0 / ((jw + 1) * (0.5 * jw + 1) * (0.2 * jw + 1)) + +magnitude_db = 20 * np.log10(np.abs(G)) +phase_deg = np.degrees(np.unwrap(np.angle(G))) + +# Gain crossover: where magnitude crosses 0 dB +gc_idx = np.argmin(np.abs(magnitude_db)) +gc_freq = frequency_hz[gc_idx] +phase_at_gc = phase_deg[gc_idx] +phase_margin = 180 + phase_at_gc + +# Phase crossover: where phase crosses -180 degrees +pc_idx = np.argmin(np.abs(phase_deg + 180)) +pc_freq = frequency_hz[pc_idx] +mag_at_pc = magnitude_db[pc_idx] +gain_margin = -mag_at_pc + +# Limit magnitude display to relevant range (above -50 dB) to avoid +# compressing the interesting region around 0 dB +freq_mag = frequency_hz[magnitude_db >= -50] +mag_display = magnitude_db[magnitude_db >= -50] + +# Panel categories +panels = ["Magnitude (dB)", "Phase (degrees)"] +panel_cat = pd.CategoricalDtype(categories=panels, ordered=True) + +# Long-format data for faceted plot +df = pd.concat( + [ + pd.DataFrame({"freq": freq_mag, "value": mag_display, "panel": "Magnitude (dB)"}), + pd.DataFrame({"freq": frequency_hz, "value": phase_deg, "panel": "Phase (degrees)"}), + ], + ignore_index=True, +) +df["panel"] = df["panel"].astype(panel_cat) + +# Reference lines: 0 dB and -180° +ref_lines = pd.DataFrame({"panel": pd.Categorical(panels, dtype=panel_cat), "yintercept": [0.0, -180.0]}) + +# Margin segments and crossover markers +gm_seg = pd.DataFrame( + {"x": [pc_freq], "ymin": [mag_at_pc], "ymax": [0.0], "panel": pd.Categorical(["Magnitude (dB)"], dtype=panel_cat)} +) +pm_seg = pd.DataFrame( + { + "x": [gc_freq], + "ymin": [-180.0], + "ymax": [phase_at_gc], + "panel": pd.Categorical(["Phase (degrees)"], dtype=panel_cat), + } +) + +markers = pd.DataFrame( + { + "freq": [gc_freq, gc_freq, pc_freq, pc_freq], + "value": [0.0, phase_at_gc, mag_at_pc, -180.0], + "panel": pd.Categorical( + ["Magnitude (dB)", "Phase (degrees)", "Magnitude (dB)", "Phase (degrees)"], dtype=panel_cat + ), + "mtype": ["gc", "gc", "pc", "pc"], + } +) + +# Annotation labels positioned to the right of margin segments +gm_label = pd.DataFrame( + { + "freq": [pc_freq * 2.0], + "value": [mag_at_pc / 2], + "label": [f"GM = {gain_margin:.1f} dB"], + "panel": pd.Categorical(["Magnitude (dB)"], dtype=panel_cat), + } +) +pm_label = pd.DataFrame( + { + "freq": [gc_freq * 2.0], + "value": [(phase_at_gc - 180) / 2], + "label": [f"PM = {phase_margin:.0f}°"], + "panel": pd.Categorical(["Phase (degrees)"], dtype=panel_cat), + } +) + +# Colors +PYTHON_BLUE = "#306998" +GM_COLOR = "#D35400" +PM_COLOR = "#7D3C98" +DARK_TEXT = "#1A237E" +MID_TEXT = "#37474F" +LIGHT_TEXT = "#546E7A" + +# Subtle vertical guides at crossover frequencies +guides = pd.DataFrame( + { + "xintercept": [gc_freq, gc_freq, pc_freq, pc_freq], + "panel": pd.Categorical( + ["Magnitude (dB)", "Phase (degrees)", "Magnitude (dB)", "Phase (degrees)"], dtype=panel_cat + ), + } +) + +# Plot — landscape format for optimal log-frequency axis display +plot = ( + ggplot(df, aes(x="freq", y="value")) + + geom_line(size=2.5, color=PYTHON_BLUE, alpha=0.92) + # Reference lines + + geom_hline(ref_lines, aes(yintercept="yintercept"), linetype="dashed", color="#90A4AE", size=0.8) + # Crossover guide lines + + geom_vline(guides, aes(xintercept="xintercept"), linetype="dotted", color="#B0BEC5", size=0.5) + # Gain margin segment + + geom_segment(gm_seg, aes(x="x", xend="x", y="ymin", yend="ymax"), color=GM_COLOR, size=5.0, alpha=0.9) + # Phase margin segment + + geom_segment(pm_seg, aes(x="x", xend="x", y="ymin", yend="ymax"), color=PM_COLOR, size=5.0, alpha=0.9) + # Gain crossover markers (purple circles) + + geom_point( + markers[markers["mtype"] == "gc"], + aes(x="freq", y="value"), + color=PM_COLOR, + fill=PM_COLOR, + size=7, + shape="o", + stroke=2.5, + ) + # Phase crossover markers (orange squares) + + geom_point( + markers[markers["mtype"] == "pc"], + aes(x="freq", y="value"), + color=GM_COLOR, + fill=GM_COLOR, + size=7, + shape="s", + stroke=2.5, + ) + # Annotations + + geom_text( + gm_label, aes(x="freq", y="value", label="label"), color=GM_COLOR, size=18, fontweight="bold", ha="left" + ) + + geom_text( + pm_label, aes(x="freq", y="value", label="label"), color=PM_COLOR, size=18, fontweight="bold", ha="left" + ) + + facet_wrap("~panel", ncol=1, scales="free_y") + + scale_x_log10( + breaks=[0.1, 1, 10], labels=["0.1", "1", "10"], minor_breaks=[0.03, 0.05, 0.2, 0.3, 0.5, 2, 3, 5, 20, 30] + ) + + scale_y_continuous(labels=lambda lst: [f"{v:.0f}" for v in lst]) + + labs(x="Frequency (Hz)", y="", title="bode-basic · plotnine · pyplots.ai") + + theme_minimal() + + theme( + figure_size=(16, 9), + text=element_text(size=14, color=MID_TEXT), + axis_title=element_text(size=20, color=MID_TEXT), + axis_text=element_text(size=16, color=LIGHT_TEXT), + axis_ticks=element_line(color="#CFD8DC", size=0.4), + plot_title=element_text(size=24, weight="bold", ha="center", color=DARK_TEXT), + strip_text=element_text(size=20, weight="bold", color=DARK_TEXT), + strip_background=element_rect(fill="#E8EAF6", color="none"), + panel_grid_major=element_line(color="#E0E0E0", size=0.25), + panel_grid_minor=element_line(color="#F5F5F5", size=0.12), + panel_spacing_y=0.35, + plot_background=element_rect(fill="#FAFAFA", color="#FAFAFA"), + panel_background=element_rect(fill="white", color="none"), + ) +) + +# Save +plot.save("plot.png", dpi=300, verbose=False) diff --git a/plots/bode-basic/metadata/plotnine.yaml b/plots/bode-basic/metadata/plotnine.yaml new file mode 100644 index 0000000000..83b4f4ab71 --- /dev/null +++ b/plots/bode-basic/metadata/plotnine.yaml @@ -0,0 +1,238 @@ +library: plotnine +specification_id: bode-basic +created: '2026-03-21T20:48:02Z' +updated: '2026-03-21T21:37:55Z' +generated_by: claude-opus-4-5-20251101 +workflow_run: 23388395508 +issue: 4411 +python_version: 3.14.3 +library_version: 0.15.3 +preview_url: https://storage.googleapis.com/pyplots-images/plots/bode-basic/plotnine/plot.png +preview_thumb: https://storage.googleapis.com/pyplots-images/plots/bode-basic/plotnine/plot_thumb.png +preview_html: null +quality_score: 81 +review: + strengths: + - Excellent data storytelling with clearly annotated gain and phase margins using + distinct colors and marker shapes + - Strong aesthetic sophistication with custom color palette, themed backgrounds, + and refined grid styling + - Correct title format and complete spec compliance with all required features + - Clean, well-structured code with deterministic data generation + - Good use of plotnine grammar of graphics with faceted layout and layer composition + weaknesses: + - Severe layout imbalance between magnitude and phase panels — magnitude panel has + massive empty white space while phase panel is extremely compressed + - Phase panel crowding causes markers, annotations, and reference lines to cluster + together + - The facet_wrap equal-height allocation does not suit this plot where panels need + different vertical proportions + image_description: The plot shows a dual-panel Bode plot with the title "bode-basic + · plotnine · pyplots.ai" centered at the top in dark blue bold text. The top panel + is labeled "Magnitude (dB)" with a light lavender strip background, showing a + blue line (#306998) starting at approximately +14 dB at low frequencies, staying + relatively flat until about 0.3 Hz, then rolling off steeply to about -50 dB at + the right edge. The bottom panel is labeled "Phase (degrees)" with the same strip + styling, showing the phase curve starting near -30° and rolling off to approximately + -270°. Reference lines are drawn as dashed light gray at 0 dB and -180°. An orange + vertical segment marks the gain margin (GM = 8.0 dB) at the phase crossover frequency + (~0.5 Hz) with orange square markers, and a purple vertical segment marks the + phase margin (PM = 33°) at the gain crossover frequency with purple circle markers. + Dotted vertical guide lines indicate crossover frequencies in both panels. The + background is light gray (#FAFAFA) with white panel backgrounds and subtle grid + lines. The major layout issue is that the magnitude panel occupies roughly 65% + of the figure height with vast empty white space below the data, while the phase + panel is extremely compressed at the bottom, making its elements crowded and hard + to distinguish. + criteria_checklist: + visual_quality: + score: 22 + max: 30 + items: + - id: VQ-01 + name: Text Legibility + score: 7 + max: 8 + passed: true + comment: All font sizes explicitly set (title=24, axis_title=20, axis_text=16, + strip=20, annotations=18). Readable throughout. + - id: VQ-02 + name: No Overlap + score: 4 + max: 6 + passed: true + comment: Minor crowding in phase panel where tick labels and PM annotation + are close together. + - id: VQ-03 + name: Element Visibility + score: 4 + max: 6 + passed: true + comment: Lines visible but phase panel compression makes margin segment and + markers hard to distinguish. + - id: VQ-04 + name: Color Accessibility + score: 4 + max: 4 + passed: true + comment: 'Colorblind-safe: blue, orange, purple are clearly distinguishable.' + - id: VQ-05 + name: Layout & Canvas + score: 1 + max: 4 + passed: false + comment: 'Severe layout imbalance: magnitude panel has massive empty space, + phase panel extremely compressed.' + - id: VQ-06 + name: Axis Labels & Title + score: 2 + max: 2 + passed: true + comment: Frequency (Hz) with units, strip labels serve as y-axis labels with + units. + design_excellence: + score: 14 + max: 20 + items: + - id: DE-01 + name: Aesthetic Sophistication + score: 6 + max: 8 + passed: true + comment: Custom color palette with intentional hierarchy, styled backgrounds, + above defaults. + - id: DE-02 + name: Visual Refinement + score: 4 + max: 6 + passed: true + comment: theme_minimal, custom grid colors, subtle grid weights, custom tick + colors. + - id: DE-03 + name: Data Storytelling + score: 4 + max: 6 + passed: true + comment: GM/PM annotations with distinct colors and marker shapes create visual + hierarchy. + spec_compliance: + score: 14 + max: 15 + items: + - id: SC-01 + name: Plot Type + score: 5 + max: 5 + passed: true + comment: Correct dual-panel Bode plot with magnitude and phase sharing log + frequency axis. + - id: SC-02 + name: Required Features + score: 4 + max: 4 + passed: true + comment: 'All features present: GM/PM annotations, reference lines, log axis, + grid.' + - id: SC-03 + name: Data Mapping + score: 2 + max: 3 + passed: true + comment: Correct mapping but phase panel compression makes it difficult to + read precise values. + - id: SC-04 + name: Title & Legend + score: 3 + max: 3 + passed: true + comment: Title format correct. No legend needed for single-system plot. + data_quality: + score: 14 + max: 15 + items: + - id: DQ-01 + name: Feature Coverage + score: 5 + max: 6 + passed: true + comment: Shows 3rd-order TF with clear GM and PM. Both crossover frequencies + visible. No resonance peak. + - id: DQ-02 + name: Realistic Context + score: 5 + max: 5 + passed: true + comment: 'Realistic control systems TF: G(s)=5/[(s+1)(0.5s+1)(0.2s+1)]. GM=8dB, + PM=33° are realistic.' + - id: DQ-03 + name: Appropriate Scale + score: 4 + max: 4 + passed: true + comment: Frequency range 0.03-30 Hz appropriate. Magnitude and phase values + physically correct. + code_quality: + score: 10 + max: 10 + items: + - id: CQ-01 + name: KISS Structure + score: 3 + max: 3 + passed: true + comment: 'Clean linear flow: imports, data, margins, DataFrame, plot, save.' + - id: CQ-02 + name: Reproducibility + score: 2 + max: 2 + passed: true + comment: Fully deterministic analytical transfer function. + - id: CQ-03 + name: Clean Imports + score: 2 + max: 2 + passed: true + comment: All imports used. + - id: CQ-04 + name: Code Elegance + score: 2 + max: 2 + passed: true + comment: Well-structured, appropriate complexity, no fake UI. + - id: CQ-05 + name: Output & API + score: 1 + max: 1 + passed: true + comment: Saves as plot.png with dpi=300, current API. + library_mastery: + score: 7 + max: 10 + items: + - id: LM-01 + name: Idiomatic Usage + score: 4 + max: 5 + passed: true + comment: 'Good grammar of graphics: facet_wrap, aes mappings, layer composition, + CategoricalDtype.' + - id: LM-02 + name: Distinctive Features + score: 3 + max: 5 + passed: true + comment: facet_wrap with free_y, CategoricalDtype for panel ordering, ggplot2-style + layer composition. + verdict: REJECTED +impl_tags: + dependencies: [] + techniques: + - annotations + - faceting + - layer-composition + patterns: + - data-generation + dataprep: [] + styling: + - grid-styling + - alpha-blending