{"spec_id":"windrose-basic","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\nwindrose-basic: Wind Rose Chart\nLibrary: plotnine 0.15.7 | Python 3.13.14\nQuality: 90/100 | Updated: 2026-08-05\n\"\"\"\n\nimport math\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    coord_equal,\n    element_blank,\n    element_rect,\n    element_text,\n    geom_path,\n    geom_polygon,\n    geom_text,\n    ggplot,\n    guide_legend,\n    labs,\n    scale_fill_manual,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n)\n\n\n# Theme tokens (see prompts/default-style-guide.md \"Background\" + \"Theme-adaptive Chrome\")\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Data - Simulated wind measurements from an airport runway environment\n# 8 direction bins: N, NE, E, SE, S, SW, W, NW\n# 5 speed bins: 0-5, 5-10, 10-15, 15-20, 20+ m/s\n# Airport wind patterns show influence from local terrain and seasonal variations\ndirections = [\"N\", \"NE\", \"E\", \"SE\", \"S\", \"SW\", \"W\", \"NW\"]\nn_dirs = len(directions)\n\n# Wind speed bins and their labels\nspeed_bins = [\"0-5\", \"5-10\", \"10-15\", \"15-20\", \"20+\"]\n\n# Frequencies (%) for each direction and speed bin - sums to ~100% of the full year of\n# observations (N/S dominance, minimal calm winds)\nfrequencies = {\n    \"N\": [4.8, 6.0, 4.3, 1.9, 0.7],\n    \"NE\": [4.3, 3.6, 1.9, 0.7, 0.2],\n    \"E\": [3.6, 2.9, 1.4, 0.5, 0.0],\n    \"SE\": [4.3, 3.6, 2.2, 1.0, 0.2],\n    \"S\": [5.3, 6.7, 4.5, 2.2, 1.0],  # Secondary wind direction\n    \"SW\": [4.8, 4.3, 2.4, 1.2, 0.5],\n    \"W\": [3.6, 2.9, 1.4, 0.5, 0.0],\n    \"NW\": [4.3, 3.6, 1.9, 0.7, 0.2],\n}\n\n# Colors for wind speed bins: cool (calm) to warm (strong) progression\n# imprint anchors arranged to keep the intensity ramp monotonic\nspeed_colors = {\n    \"0-5\": \"#4467A3\",  # imprint blue (calm)\n    \"5-10\": \"#2ABCCD\",  # imprint cyan\n    \"10-15\": \"#009E73\",  # imprint green (mid)\n    \"15-20\": \"#DDCC77\",  # imprint amber (caution)\n    \"20+\": \"#AE3030\",  # imprint red (strong, hottest)\n}\n\n# Calculate direction angles (N=top, clockwise)\n# N is at 90 degrees (top), going clockwise\ndir_angles = {\n    \"N\": math.pi / 2,\n    \"NE\": math.pi / 4,\n    \"E\": 0,\n    \"SE\": -math.pi / 4,\n    \"S\": -math.pi / 2,\n    \"SW\": -3 * math.pi / 4,\n    \"W\": math.pi,\n    \"NW\": 3 * math.pi / 4,\n}\n\n# Create stacked wedges for each direction\nwedge_rows = []\nn_arc_points = 20  # Points along the arc for smooth edges\nwedge_width = 2 * math.pi / n_dirs  # Width of each direction bin\n\nwedge_id = 0\nfor direction in directions:\n    center_angle = dir_angles[direction]\n    start_angle = center_angle + wedge_width / 2 - 0.03  # Small gap\n    end_angle = center_angle - wedge_width / 2 + 0.03\n\n    cumulative_radius = 0\n    for speed_idx, speed_bin in enumerate(speed_bins):\n        freq = frequencies[direction][speed_idx]\n        if freq <= 0:\n            continue\n\n        # Inner and outer radius for this stack segment\n        inner_radius = cumulative_radius\n        outer_radius = cumulative_radius + freq\n\n        # Build wedge polygon: inner arc -> outer arc -> close\n        # Start with inner arc (from start to end angle)\n        arc_angles = np.linspace(start_angle, end_angle, n_arc_points)\n\n        # Inner arc points (counterclockwise from start to end)\n        for angle in arc_angles:\n            x = inner_radius * math.cos(angle)\n            y = inner_radius * math.sin(angle)\n            wedge_rows.append({\"x\": x, \"y\": y, \"wedge_id\": wedge_id, \"speed\": speed_bin, \"direction\": direction})\n\n        # Outer arc points (clockwise from end to start)\n        for angle in reversed(arc_angles):\n            x = outer_radius * math.cos(angle)\n            y = outer_radius * math.sin(angle)\n            wedge_rows.append({\"x\": x, \"y\": y, \"wedge_id\": wedge_id, \"speed\": speed_bin, \"direction\": direction})\n\n        # Close the polygon\n        first_x = inner_radius * math.cos(start_angle)\n        first_y = inner_radius * math.sin(start_angle)\n        wedge_rows.append(\n            {\"x\": first_x, \"y\": first_y, \"wedge_id\": wedge_id, \"speed\": speed_bin, \"direction\": direction}\n        )\n\n        cumulative_radius = outer_radius\n        wedge_id += 1\n\ndf = pd.DataFrame(wedge_rows)\n\n# Preserve speed order for legend\ndf[\"speed\"] = pd.Categorical(df[\"speed\"], categories=speed_bins, ordered=True)\n\n# Create radial gridlines (circles at frequency percentages)\ngrid_rows = []\ngrid_angles = np.linspace(0, 2 * math.pi, 101)\ngrid_radii = [5, 10, 15]  # Frequency percentage circles\n\nfor radius in grid_radii:\n    for angle in grid_angles:\n        grid_rows.append({\"x\": radius * math.cos(angle), \"y\": radius * math.sin(angle), \"radius\": radius})\n\ngrid_df = pd.DataFrame(grid_rows)\n\n# Create spoke lines (one for each direction)\nspoke_rows = []\nmax_radius = 21  # Extend spokes beyond the tallest stack (S ~ 19.7%)\nfor i, direction in enumerate(directions):\n    angle = dir_angles[direction]\n    spoke_rows.append({\"x\": 0, \"y\": 0, \"spoke_id\": i})\n    spoke_rows.append({\"x\": max_radius * math.cos(angle), \"y\": max_radius * math.sin(angle), \"spoke_id\": i})\n\nspoke_df = pd.DataFrame(spoke_rows)\n\n# Create direction labels positioned outside the chart\nlabel_rows = []\nlabel_radius = 23\nfor direction in directions:\n    angle = dir_angles[direction]\n    label_rows.append({\"label\": direction, \"x\": label_radius * math.cos(angle), \"y\": label_radius * math.sin(angle)})\n\nlabel_df = pd.DataFrame(label_rows)\n\n# Create frequency labels on gridlines, positioned in the angular gap between the E and\n# NE wedges (each wedge is inset 0.03 rad from its sector boundary) so the label always\n# lands on blank background instead of on top of a colored stack segment\nfreq_label_rows = []\nfreq_label_angle = math.pi / 8\nfor radius in grid_radii:\n    freq_label_rows.append(\n        {\"label\": f\"{radius}%\", \"x\": radius * math.cos(freq_label_angle), \"y\": radius * math.sin(freq_label_angle)}\n    )\n\nfreq_label_df = pd.DataFrame(freq_label_rows)\n\n# Create \"Frequency (%)\" label to explain what gridlines represent\nfreq_axis_label_df = pd.DataFrame([{\"label\": \"Frequency (%)\", \"x\": -2.5, \"y\": label_radius + 1.5}])\n\n# Data-driven insight for the subtitle: identify the dominant direction corridor\n# (a direction and its 180-degree opposite) by summed frequency share\ndirection_totals = {d: sum(frequencies[d]) for d in directions}\nopposite = {\"N\": \"S\", \"NE\": \"SW\", \"E\": \"W\", \"SE\": \"NW\", \"S\": \"N\", \"SW\": \"NE\", \"W\": \"E\", \"NW\": \"SE\"}\ntotal_frequency = sum(direction_totals.values())\ncorridor_share = {d: direction_totals[d] + direction_totals[opposite[d]] for d in directions if d < opposite[d]}\ntop_corridor = max(corridor_share, key=corridor_share.get)\ntop_corridor_pct = round(100 * corridor_share[top_corridor] / total_frequency)\nsubtitle = (\n    f\"{top_corridor}–{opposite[top_corridor]} corridor carries {top_corridor_pct}% of observations, \"\n    \"the strongest axis in the record\"\n)\n\n# Plot\nplot = (\n    ggplot()\n    # Gridlines (circles) — geom_path preserves point order (pre-sorted by angle),\n    # unlike geom_line which would re-sort by x and zigzag across the circle\n    + geom_path(\n        aes(x=\"x\", y=\"y\", group=\"radius\"), data=grid_df, color=INK_SOFT, size=0.3, alpha=0.25, linetype=\"dashed\"\n    )\n    # Spoke lines\n    + geom_path(aes(x=\"x\", y=\"y\", group=\"spoke_id\"), data=spoke_df, color=INK_SOFT, size=0.25, alpha=0.35)\n    # Wind rose wedges (stacked)\n    + geom_polygon(aes(x=\"x\", y=\"y\", fill=\"speed\", group=\"wedge_id\"), data=df, color=PAGE_BG, size=0.3, alpha=0.95)\n    # Direction labels\n    + geom_text(aes(x=\"x\", y=\"y\", label=\"label\"), data=label_df, size=16, fontweight=\"bold\", color=INK)\n    # Frequency labels\n    + geom_text(aes(x=\"x\", y=\"y\", label=\"label\"), data=freq_label_df, size=10, color=INK_SOFT, fontweight=\"bold\")\n    # Frequency axis label\n    + geom_text(aes(x=\"x\", y=\"y\", label=\"label\"), data=freq_axis_label_df, size=9, color=INK_SOFT, fontstyle=\"italic\")\n    # Colors with native legend\n    + scale_fill_manual(values=speed_colors, name=\"Wind Speed (m/s)\", guide=guide_legend(reverse=False))\n    # Axis scaling\n    + scale_x_continuous(limits=(-25, 25))\n    + scale_y_continuous(limits=(-25, 25))\n    + coord_equal()\n    # Title + data-driven subtitle (Data Storytelling: names the dominant corridor)\n    + labs(title=\"windrose-basic · python · plotnine · anyplot.ai\", subtitle=subtitle)\n    # Theme for clean wind rose appearance\n    + theme(\n        figure_size=(6, 6),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG, color=None),\n        plot_title=element_text(size=12, ha=\"center\", color=INK, weight=\"bold\"),\n        plot_subtitle=element_text(size=8, ha=\"center\", color=INK_MUTED, style=\"italic\"),\n        axis_title=element_blank(),\n        axis_text=element_blank(),\n        axis_ticks=element_blank(),\n        axis_line=element_blank(),\n        panel_grid_major=element_blank(),\n        panel_grid_minor=element_blank(),\n        legend_position=\"bottom\",\n        legend_title=element_text(size=9, weight=\"bold\", color=INK),\n        legend_text=element_text(size=8, color=INK_SOFT),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT, size=0.4),\n        legend_key=element_rect(fill=ELEVATED_BG, color=None),\n        legend_key_size=14,\n    )\n)\n\n# Save\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=6, height=6, units=\"in\")\n"}