Skip to content
156 changes: 156 additions & 0 deletions plots/campbell-basic/implementations/pygal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
""" pyplots.ai
campbell-basic: Campbell Diagram
Library: pygal 3.1.0 | Python 3.14.3
Quality: 92/100 | Created: 2026-02-15
"""

import numpy as np
import pygal
from pygal.style import Style


# Data — natural frequencies of a rotor system vs rotational speed
np.random.seed(42)
speed_rpm = np.linspace(0, 6000, 80)
speed_hz = speed_rpm / 60

# Natural frequency modes (Hz) with gyroscopic effects
mode1 = 25 + 0.003 * speed_rpm + np.random.normal(0, 0.15, len(speed_rpm))
mode2 = 48 + 0.005 * speed_rpm + np.random.normal(0, 0.15, len(speed_rpm))
mode3 = 62 - 0.001 * speed_rpm + np.random.normal(0, 0.15, len(speed_rpm))
mode4 = 78 - 0.002 * speed_rpm + np.random.normal(0, 0.15, len(speed_rpm))
mode5 = 92 + 0.004 * speed_rpm + np.random.normal(0, 0.15, len(speed_rpm))

orders = [1, 2, 3]
modes_data = [mode1, mode2, mode3, mode4, mode5]
mode_names = ["1st Bending", "2nd Bending", "1st Torsional", "Axial", "2nd Torsional"]

# Find critical speed intersections
critical_speeds = []
critical_info = []
for order in orders:
eo_freq = order * speed_hz
for mi, mode in enumerate(modes_data):
diff = eo_freq - mode
sign_changes = np.where(np.diff(np.sign(diff)))[0]
for idx in sign_changes:
frac = abs(diff[idx]) / (abs(diff[idx]) + abs(diff[idx + 1]))
rpm_interp = speed_rpm[idx] + frac * (speed_rpm[idx + 1] - speed_rpm[idx])
freq_interp = order * rpm_interp / 60
if 0 < rpm_interp < 6000:
critical_speeds.append((float(rpm_interp), float(freq_interp)))
critical_info.append((order, mode_names[mi]))

# Style — stroke_width controls .reactive CSS base width for all line elements
# Setting it high ensures EO dashed lines are fully visible in CairoSVG PNG rendering
font = "DejaVu Sans, Helvetica, Arial, sans-serif"
custom_style = Style(
background="white",
plot_background="white",
foreground="#2a2a2a",
foreground_strong="#2a2a2a",
foreground_subtle="#d0d0d0",
guide_stroke_color="#e4e4e4",
guide_stroke_dasharray="4, 6",
major_guide_stroke_dasharray="2, 4",
colors=(
"#306998", # 1st Bending — Python Blue
"#1a9988", # 2nd Bending — teal
"#7b5ea7", # 1st Torsional — purple
"#d4812e", # Axial — orange
"#5a8c3c", # 2nd Torsional — green
"#b71c1c", # 1x EO — dark red
"#0d47a1", # 2x EO — bold blue
"#4a148c", # 3x EO — bold purple
"#d50000", # Critical Speeds — vivid red
),
font_family=font,
title_font_family=font,
title_font_size=56,
label_font_size=42,
major_label_font_size=38,
legend_font_size=32,
legend_font_family=font,
value_font_size=28,
tooltip_font_size=28,
tooltip_font_family=font,
opacity=1.0,
opacity_hover=1.0,
stroke_opacity=1.0,
stroke_opacity_hover=1.0,
stroke_width=6,
)

chart = pygal.XY(
width=4800,
height=2700,
style=custom_style,
title="campbell-basic · pygal · pyplots.ai",
x_title="Rotational Speed (RPM)",
y_title="Frequency (Hz)",
show_legend=True,
legend_at_bottom=True,
legend_at_bottom_columns=3,
legend_box_size=30,
stroke=True,
dots_size=0,
show_x_guides=True,
show_y_guides=True,
x_value_formatter=lambda x: f"{x:,.0f}",
value_formatter=lambda y: f"{y:.1f}",
margin_bottom=80,
margin_left=100,
margin_right=60,
margin_top=50,
x_label_rotation=0,
truncate_legend=-1,
range=(0, 130),
xrange=(0, 6000),
print_values=False,
print_zeroes=False,
tooltip_fancy_mode=True,
js=[],
)

# Natural frequency mode curves — solid, thick lines with cubic interpolation
# Add label point near right end of each curve for direct labeling
for mode, label in zip(modes_data, mode_names, strict=True):
points = []
label_idx = int(len(speed_rpm) * 0.82)
for j, (r, f) in enumerate(zip(speed_rpm, mode, strict=True)):
if j == label_idx:
points.append({"value": (float(r), float(f)), "label": label})
else:
points.append((float(r), float(f)))
chart.add(label, points, stroke_style={"width": 10, "linecap": "round"}, show_dots=False, interpolate="cubic")

# Engine order lines — dashed, bold, many sample points for proper rendering
# Using multiple points along each line ensures CairoSVG renders the full stroke
eo_labels = ["1× EO", "2× EO", "3× EO"]
eo_dash_patterns = ["28, 14", "20, 10, 8, 10", "14, 8"]
for order, eo_label, dash in zip(orders, eo_labels, eo_dash_patterns, strict=True):
eo_end_rpm = min(6000.0, 130.0 * 60.0 / order)
eo_end_hz = order * eo_end_rpm / 60.0
# Generate 40 evenly spaced points so pygal renders a proper visible path
eo_rpms = np.linspace(0, eo_end_rpm, 40)
eo_freqs = order * eo_rpms / 60.0
# Place label near 70% of line length
label_idx = int(len(eo_rpms) * 0.70)
eo_points = []
for j, (r, f) in enumerate(zip(eo_rpms, eo_freqs, strict=True)):
if j == label_idx:
eo_points.append({"value": (float(r), float(f)), "label": eo_label})
else:
eo_points.append((float(r), float(f)))
chart.add(eo_label, eo_points, stroke_style={"width": 8, "dasharray": dash, "linecap": "round"}, show_dots=False)

# Critical speed markers — vivid red with tooltip showing intersection details
critical_points = []
for pt, info in zip(critical_speeds, critical_info, strict=True):
order, mname = info
critical_points.append({"value": pt, "label": f"{mname} × {order}× EO\n{pt[0]:.0f} RPM / {pt[1]:.1f} Hz"})
chart.add("Critical Speeds", critical_points, stroke=False, dots_size=22)

# Render both SVG/HTML (leveraging pygal's native SVG interactivity) and PNG
chart.render_to_file("plot.html")
chart.render_to_png("plot.png")
238 changes: 238 additions & 0 deletions plots/campbell-basic/metadata/pygal.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
library: pygal
specification_id: campbell-basic
created: '2026-02-15T21:09:18Z'
updated: '2026-02-15T21:36:47Z'
generated_by: claude-opus-4-5-20251101
workflow_run: 22043026953
issue: 4241
python_version: 3.14.3
library_version: 3.1.0
preview_url: https://storage.googleapis.com/pyplots-images/plots/campbell-basic/pygal/plot.png
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/campbell-basic/pygal/plot_thumb.png
preview_html: https://storage.googleapis.com/pyplots-images/plots/campbell-basic/pygal/plot.html
quality_score: 92
review:
strengths:
- 'Comprehensive Campbell Diagram with all required elements: 5 mode curves, 3 engine
order lines, and critical speed markers with detailed intersection info'
- Excellent font sizing and styling explicitly configured for 4800x2700 resolution
- all text is clearly legible
- 'Effective visual hierarchy: thick solid lines for modes, dashed lines for engine
orders, large red dots for critical speeds - viewer immediately understands the
diagram'
- Strong use of pygal-specific features including per-point label dicts, dual output
(SVG+PNG), and cubic interpolation for smooth curves
- Realistic engineering data with proper mode naming conventions and physically
plausible frequency-speed relationships
weaknesses:
- The 2x EO dark blue dashed line (#0d47a1) is somewhat similar to the 1st Bending
solid blue (#306998) - using a more contrasting color for EO lines would improve
differentiation
- Engine order lines could benefit from slightly thicker strokes or more prominent
dash patterns to stand out more distinctly from the mode curves in the PNG rendering
image_description: 'The plot displays a Campbell Diagram with the title "campbell-basic
· pygal · pyplots.ai" at the top. The X-axis shows "Rotational Speed (RPM)" ranging
from 0 to 6,000, and the Y-axis shows "Frequency (Hz)" from 0.0 to 130.0. Five
natural frequency mode curves are rendered as thick solid lines: 1st Bending (blue,
rising gently from ~25 Hz), 2nd Bending (teal, rising from ~49 Hz), 1st Torsional
(purple, declining slightly from ~62 Hz), Axial (orange, declining from ~78 Hz),
and 2nd Torsional (green, rising from ~92 Hz). Three engine order excitation lines
are drawn as dashed diagonals from the origin: 1x EO (red dashed), 2x EO (dark
blue dashed), and 3x EO (purple dashed). Large vivid red dots mark Critical Speed
intersections where engine order lines cross natural frequency curves. A legend
at the bottom in three columns identifies all nine series. The background is white
with subtle gray grid lines. The overall layout is clean and well-proportioned.'
criteria_checklist:
visual_quality:
score: 29
max: 30
items:
- id: VQ-01
name: Text Legibility
score: 8
max: 8
passed: true
comment: All font sizes explicitly set (title=56, label=42, major_label=38,
legend=32). All text clearly readable.
- id: VQ-02
name: No Overlap
score: 6
max: 6
passed: true
comment: No overlapping text elements. Legend at bottom well-organized in
3 columns.
- id: VQ-03
name: Element Visibility
score: 6
max: 6
passed: true
comment: Mode curves stroke width 10, EO lines width 8, critical dots size
22. All well-visible.
- id: VQ-04
name: Color Accessibility
score: 3
max: 4
passed: true
comment: Colors generally distinguishable but 2x EO dark blue somewhat close
to 1st Bending blue. Mitigated by different line styles.
- id: VQ-05
name: Layout Balance
score: 4
max: 4
passed: true
comment: Plot fills canvas well with balanced margins. Good utilization of
4800x2700 canvas.
- id: VQ-06
name: Axis Labels & Title
score: 2
max: 2
passed: true
comment: 'Both axes have descriptive labels with units: Rotational Speed (RPM)
and Frequency (Hz).'
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, explicit font family,
subtle grid styling. Strong design above defaults.
- id: DE-02
name: Visual Refinement
score: 4
max: 6
passed: true
comment: Custom grid dash patterns, subtle guide colors, generous margins,
clean white background.
- id: DE-03
name: Data Storytelling
score: 4
max: 6
passed: true
comment: 'Visual hierarchy guides the reader: thick solid for modes, dashed
for EO, large red dots for critical speeds.'
spec_compliance:
score: 15
max: 15
items:
- id: SC-01
name: Plot Type
score: 5
max: 5
passed: true
comment: Correct Campbell Diagram with natural frequency curves, engine order
lines, and critical speed markers.
- id: SC-02
name: Required Features
score: 4
max: 4
passed: true
comment: 'All spec features present: 5 modes, 3 EO lines, critical speed markers
with labels.'
- id: SC-03
name: Data Mapping
score: 3
max: 3
passed: true
comment: X=RPM, Y=Frequency(Hz). Correct assignment with proper range.
- id: SC-04
name: Title & Legend
score: 3
max: 3
passed: true
comment: Title matches required format. Legend labels correctly identify all
data series.
data_quality:
score: 15
max: 15
items:
- id: DQ-01
name: Feature Coverage
score: 6
max: 6
passed: true
comment: Shows 5 modes with increasing and decreasing frequency trends, 3
engine orders, multiple critical speed intersections.
- id: DQ-02
name: Realistic Context
score: 5
max: 5
passed: true
comment: Rotordynamic analysis with proper engineering mode names. Authentic
domain vocabulary.
- id: DQ-03
name: Appropriate Scale
score: 4
max: 4
passed: true
comment: 0-6000 RPM range, 25-92 Hz base frequencies. Realistic for rotating
machinery.
code_quality:
score: 10
max: 10
items:
- id: CQ-01
name: KISS Structure
score: 3
max: 3
passed: true
comment: 'Clean flow: Imports, Seed, Data, Style, Chart, Series, Render. No
functions or classes.'
- id: CQ-02
name: Reproducibility
score: 2
max: 2
passed: true
comment: np.random.seed(42) set at the start.
- id: CQ-03
name: Clean Imports
score: 2
max: 2
passed: true
comment: Only numpy, pygal, and Style imported - all used.
- id: CQ-04
name: Code Elegance
score: 2
max: 2
passed: true
comment: Clean, Pythonic code with appropriately complex intersection-finding
logic.
- id: CQ-05
name: Output & API
score: 1
max: 1
passed: true
comment: Saves as plot.png and plot.html. No deprecated API usage.
library_mastery:
score: 9
max: 10
items:
- id: LM-01
name: Idiomatic Usage
score: 5
max: 5
passed: true
comment: Expert use of pygal.XY with Style, per-series stroke_style, value
formatters, legend config, cubic interpolation.
- id: LM-02
name: Distinctive Features
score: 4
max: 5
passed: true
comment: Uses pygal-specific per-point label dicts, native SVG interactivity,
cubic interpolation, stroke_style dasharray.
verdict: APPROVED
impl_tags:
dependencies: []
techniques:
- html-export
patterns:
- data-generation
- iteration-over-groups
dataprep:
- interpolation
styling:
- grid-styling