{"spec_id":"windrose-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nwindrose-basic: Wind Rose Chart\nLibrary: altair 6.2.2 | Python 3.13.14\nQuality: 87/100 | Updated: 2026-08-05\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\n\n\n# Theme tokens\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\"\n\n# Data - Simulated hourly wind measurements for one year\nnp.random.seed(42)\nn_samples = 8760  # One year of hourly data\n\n# Generate wind directions with prevailing westerly/southwesterly pattern\ndirection_weights = np.array([0.05, 0.04, 0.06, 0.08, 0.12, 0.18, 0.22, 0.15, 0.06, 0.04])\ndirection_centers = np.array([0, 45, 90, 135, 180, 225, 270, 315, 337.5, 22.5])\n\ndirections = []\nfor _ in range(n_samples):\n    center_idx = np.random.choice(len(direction_centers), p=direction_weights / direction_weights.sum())\n    direction = direction_centers[center_idx] + np.random.normal(0, 15)\n    directions.append(direction % 360)\n\ndirections = np.array(directions)\n\n# Generate wind speeds with Weibull-like distribution (typical for wind)\nspeeds = np.random.weibull(2, n_samples) * 8  # Scale for realistic m/s values\n\n# Define direction bins (8 sectors)\ndirection_bins = [0, 22.5, 67.5, 112.5, 157.5, 202.5, 247.5, 292.5, 337.5, 360]\ndirection_labels = [\"N\", \"NE\", \"E\", \"SE\", \"S\", \"SW\", \"W\", \"NW\"]\n\n# Define speed bins\nspeed_bins = [0, 3, 6, 9, 12, np.inf]\nspeed_labels = [\"0-3 m/s\", \"3-6 m/s\", \"6-9 m/s\", \"9-12 m/s\", \">12 m/s\"]\n\n# Bin directions (handle wraparound at North)\ndir_binned = np.digitize(directions, direction_bins[:-1]) - 1\ndir_binned[dir_binned == 8] = 0  # Wrap 337.5-360 to North\ndir_names = [direction_labels[i] for i in dir_binned]\n\n# Bin speeds\nspeed_binned = np.digitize(speeds, speed_bins) - 1\nspeed_binned = np.clip(speed_binned, 0, len(speed_labels) - 1)\nspeed_names = [speed_labels[i] for i in speed_binned]\n\n# Create DataFrame and calculate frequencies\ndf = pd.DataFrame({\"direction\": dir_names, \"speed_range\": speed_names})\nfreq_df = df.groupby([\"direction\", \"speed_range\"]).size().reset_index(name=\"count\")\nfreq_df[\"frequency\"] = freq_df[\"count\"] / n_samples * 100\n\n# Add all combinations to ensure complete data\nall_combinations = pd.DataFrame([{\"direction\": d, \"speed_range\": s} for d in direction_labels for s in speed_labels])\nfreq_df = all_combinations.merge(freq_df, on=[\"direction\", \"speed_range\"], how=\"left\").fillna(0)\n\n# Set categorical order\ndirection_order = [\"N\", \"NE\", \"E\", \"SE\", \"S\", \"SW\", \"W\", \"NW\"]\nspeed_order = [\"0-3 m/s\", \"3-6 m/s\", \"6-9 m/s\", \"9-12 m/s\", \">12 m/s\"]\n\nfreq_df[\"direction\"] = pd.Categorical(freq_df[\"direction\"], categories=direction_order, ordered=True)\nfreq_df[\"speed_range\"] = pd.Categorical(freq_df[\"speed_range\"], categories=speed_order, ordered=True)\nfreq_df = freq_df.sort_values([\"direction\", \"speed_range\"])\n\n# Calculate angle for polar positioning (N at top = 90 degrees in standard math coords)\ndirection_angles = {\"N\": 90, \"NE\": 45, \"E\": 0, \"SE\": -45, \"S\": -90, \"SW\": -135, \"W\": 180, \"NW\": 135}\nfreq_df[\"angle\"] = freq_df[\"direction\"].map(direction_angles)\n\n# Calculate cumulative frequency for stacking\nfreq_df = freq_df.sort_values([\"direction\", \"speed_range\"])\nfreq_df[\"cumulative\"] = freq_df.groupby(\"direction\", observed=True)[\"frequency\"].cumsum()\nfreq_df[\"cumulative_start\"] = freq_df[\"cumulative\"] - freq_df[\"frequency\"]\n\n# Convert polar to cartesian for each bar segment (using arc approach)\nwedge_data = []\nbar_width = 38  # Angular width in degrees\n\nfor _, row in freq_df.iterrows():\n    if row[\"frequency\"] > 0:\n        angle_center = row[\"angle\"]\n        r_inner = row[\"cumulative_start\"]\n        r_outer = row[\"cumulative\"]\n\n        # Create arc points - trace the closed polygon\n        n_arc_points = 20\n        points = []\n\n        # Inner arc (left to right)\n        for i in range(n_arc_points + 1):\n            angle_offset = (i / n_arc_points - 0.5) * bar_width\n            angle_rad = np.radians(angle_center + angle_offset)\n            points.append((r_inner * np.cos(angle_rad), r_inner * np.sin(angle_rad)))\n\n        # Outer arc (right to left)\n        for i in range(n_arc_points, -1, -1):\n            angle_offset = (i / n_arc_points - 0.5) * bar_width\n            angle_rad = np.radians(angle_center + angle_offset)\n            points.append((r_outer * np.cos(angle_rad), r_outer * np.sin(angle_rad)))\n\n        # Add all points with order\n        for idx, (px, py) in enumerate(points):\n            wedge_data.append(\n                {\n                    \"direction\": str(row[\"direction\"]),\n                    \"speed_range\": str(row[\"speed_range\"]),\n                    \"x\": px,\n                    \"y\": py,\n                    \"path_order\": idx,\n                    \"segment_id\": f\"{row['direction']}_{row['speed_range']}\",\n                    \"frequency\": row[\"frequency\"],\n                }\n            )\n\nwedge_df = pd.DataFrame(wedge_data)\n\n# Color scale - Imprint palette, canonical order (first series is always brand green)\ncolors = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\"]\n\nmax_freq = freq_df[\"cumulative\"].max()\nmax_radius = max_freq * 1.15\naxis_range = [-max_radius - 5, max_radius + 5]\n\n# Create the wind rose chart - wedges\nwedges = (\n    alt.Chart(wedge_df)\n    .mark_line(strokeWidth=1, stroke=PAGE_BG, filled=True)\n    .encode(\n        x=alt.X(\"x:Q\").scale(domain=axis_range).axis(None),\n        y=alt.Y(\"y:Q\").scale(domain=axis_range).axis(None),\n        fill=alt.Fill(\n            \"speed_range:N\",\n            scale=alt.Scale(domain=speed_order, range=colors),\n            legend=alt.Legend(\n                title=\"Wind Speed\",\n                titleFontSize=18,\n                labelFontSize=14,\n                orient=\"right\",\n                symbolSize=260,\n                titleLimit=160,\n                symbolType=\"square\",\n            ),\n        ),\n        order=alt.Order(\"path_order:O\"),\n        detail=alt.Detail(\"segment_id:N\"),\n        tooltip=[\n            alt.Tooltip(\"direction:N\", title=\"Direction\"),\n            alt.Tooltip(\"speed_range:N\", title=\"Speed\"),\n            alt.Tooltip(\"frequency:Q\", format=\".1f\", title=\"Frequency (%)\"),\n        ],\n    )\n)\n\n# Add compass direction labels\nlabel_radius = max_radius + 3\nlabel_data = pd.DataFrame(\n    [\n        {\"label\": d, \"x\": label_radius * np.cos(np.radians(a)), \"y\": label_radius * np.sin(np.radians(a))}\n        for d, a in direction_angles.items()\n    ]\n)\n\nlabels = (\n    alt.Chart(label_data)\n    .mark_text(fontSize=24, fontWeight=\"bold\", color=INK)\n    .encode(\n        x=alt.X(\"x:Q\").scale(domain=axis_range).axis(None),\n        y=alt.Y(\"y:Q\").scale(domain=axis_range).axis(None),\n        text=\"label:N\",\n    )\n)\n\n# Add concentric circles for reference as line marks\ncircle_step = 5 if max_freq > 15 else 3\ncircle_radii = list(range(circle_step, int(max_freq) + circle_step, circle_step))\n\n# Create smooth circles using many points\ncircle_points = []\nfor r in circle_radii:\n    angles = np.linspace(0, 360, 180)\n    for point_order, angle in enumerate(angles):\n        circle_points.append(\n            {\n                \"radius\": r,\n                \"x\": r * np.cos(np.radians(angle)),\n                \"y\": r * np.sin(np.radians(angle)),\n                \"point_order\": point_order,\n            }\n        )\ncircle_df = pd.DataFrame(circle_points)\n\ncircles = (\n    alt.Chart(circle_df)\n    .mark_line(strokeWidth=1, color=INK_SOFT, opacity=0.3)\n    .encode(\n        x=alt.X(\"x:Q\").scale(domain=axis_range).axis(None),\n        y=alt.Y(\"y:Q\").scale(domain=axis_range).axis(None),\n        detail=alt.Detail(\"radius:O\"),\n        order=alt.Order(\"point_order:O\"),\n    )\n)\n\n# Add radial lines as separate rule marks using mark_rule\nradial_data = []\nfor d, a in direction_angles.items():\n    angle_rad = np.radians(a)\n    radial_data.append(\n        {\n            \"direction\": d,\n            \"x\": 0,\n            \"y\": 0,\n            \"x2\": (max_freq + 1) * np.cos(angle_rad),\n            \"y2\": (max_freq + 1) * np.sin(angle_rad),\n        }\n    )\n\nradial_df = pd.DataFrame(radial_data)\n\nradial_lines = (\n    alt.Chart(radial_df)\n    .mark_rule(strokeWidth=1, color=INK_SOFT, opacity=0.3)\n    .encode(\n        x=alt.X(\"x:Q\").scale(domain=axis_range).axis(None),\n        y=alt.Y(\"y:Q\").scale(domain=axis_range).axis(None),\n        x2=\"x2:Q\",\n        y2=\"y2:Q\",\n    )\n)\n\n# Add percentage labels on circles (positioned at 115° between N and NW to avoid NE overlap)\npct_label_data = pd.DataFrame(\n    [\n        {\"label\": f\"{r}%\", \"x\": r * np.cos(np.radians(115)) - 1.5, \"y\": r * np.sin(np.radians(115)) + 0.5}\n        for r in circle_radii\n    ]\n)\n\n# Add radial axis title \"Frequency (%)\", same 115° column as the percentage labels but\n# pushed past the outermost one (circle_radii max) so the two text elements never overlap.\naxis_title_radius = max(circle_radii) + 2\naxis_title_data = pd.DataFrame(\n    [\n        {\n            \"label\": \"Frequency (%)\",\n            \"x\": axis_title_radius * np.cos(np.radians(115)) - 3,\n            \"y\": axis_title_radius * np.sin(np.radians(115)) + 2,\n        }\n    ]\n)\n\npct_labels = (\n    alt.Chart(pct_label_data)\n    .mark_text(fontSize=16, align=\"left\", color=INK_SOFT, fontWeight=\"bold\")\n    .encode(\n        x=alt.X(\"x:Q\").scale(domain=axis_range).axis(None),\n        y=alt.Y(\"y:Q\").scale(domain=axis_range).axis(None),\n        text=\"label:N\",\n    )\n)\n\naxis_title = (\n    alt.Chart(axis_title_data)\n    .mark_text(fontSize=18, align=\"center\", color=INK, fontWeight=\"bold\", angle=335)\n    .encode(\n        x=alt.X(\"x:Q\").scale(domain=axis_range).axis(None),\n        y=alt.Y(\"y:Q\").scale(domain=axis_range).axis(None),\n        text=\"label:N\",\n    )\n)\n\n# Combine all layers - order matters: grid first, then data, then labels\nchart = (\n    (circles + radial_lines + wedges + labels + pct_labels + axis_title)\n    .properties(\n        width=460,\n        height=460,\n        background=PAGE_BG,\n        title=alt.Title(\"windrose-basic · python · altair · anyplot.ai\", fontSize=22, anchor=\"middle\", color=INK),\n    )\n    .configure_view(strokeWidth=0, fill=PAGE_BG)\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n)\n\n# Save\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n# PAD-only to the canonical 2400x2400 square target — never crop (would clip title/labels).\nTW, TH = 2400, 2400\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}x{_h}, exceeds target {TW}x{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}