{"spec_id":"windrose-basic","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nwindrose-basic: Wind Rose Chart\nLibrary: letsplot 4.11.0 | Python 3.13.14\nQuality: 84/100 | Updated: 2026-08-05\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\nfrom lets_plot import element_line, element_rect, element_text, ggsave, layer_tooltips, theme\nfrom PIL import Image\n\n\nLetsPlot.setup_html()\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nPAGE_BG_RGB = (250, 248, 241) if THEME == \"light\" else (26, 26, 23)\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# Generate realistic wind data (1 year of hourly measurements)\nnp.random.seed(42)\nn_obs = 8760  # hours in a year\n\n# Simulate prevailing westerly winds with secondary NE component\ndirection_weights = np.array([0.05, 0.08, 0.06, 0.04, 0.08, 0.12, 0.25, 0.32])\ndirection_centers = np.array([0, 45, 90, 135, 180, 225, 270, 315])\n\n# Sample directions based on weights\nchosen_sectors = np.random.choice(8, size=n_obs, p=direction_weights / direction_weights.sum())\n# Add noise within each 45° sector\ndirections = direction_centers[chosen_sectors] + np.random.uniform(-22.5, 22.5, n_obs)\ndirections = directions % 360\n\n# Wind speeds - Weibull-like distribution, varying by direction\nbase_speed = np.random.weibull(2.2, n_obs) * 6\ndirection_speed_factor = 1 + 0.3 * np.sin(np.radians(directions - 250))\nspeeds = base_speed * direction_speed_factor\nspeeds = np.clip(speeds, 0, 25)\n\n# Bin directions into 16 sectors\nn_sectors = 16\nsector_size = 360 / n_sectors\ndirection_bins = ((directions + sector_size / 2) % 360) // sector_size\n\n# Bin speeds into categories\nspeed_bins = pd.cut(\n    speeds, bins=[0, 3, 6, 9, 12, 15, 25], labels=[\"0-3 m/s\", \"3-6 m/s\", \"6-9 m/s\", \"9-12 m/s\", \"12-15 m/s\", \"15+ m/s\"]\n)\n\n# Create DataFrame for aggregation\ndf = pd.DataFrame({\"direction_bin\": direction_bins.astype(int), \"speed_bin\": speed_bins})\n\n# Aggregate counts per direction/speed combination\ncounts = df.groupby([\"direction_bin\", \"speed_bin\"], observed=True).size().reset_index(name=\"count\")\ntotal_obs = counts[\"count\"].sum()\ncounts[\"frequency\"] = counts[\"count\"] / total_obs * 100\n\n# Direction as discrete variable for x-axis\ncounts[\"direction\"] = counts[\"direction_bin\"]\n\n# Full 16-point compass labels for tooltips (axis itself only labels the 8 cardinal/intercardinal points)\ncompass_16 = [\n    \"N\", \"NNE\", \"NE\", \"ENE\", \"E\", \"ESE\", \"SE\", \"SSE\",\n    \"S\", \"SSW\", \"SW\", \"WSW\", \"W\", \"WNW\", \"NW\", \"NNW\",\n]  # fmt: skip\ncounts[\"direction_label\"] = counts[\"direction_bin\"].map(dict(enumerate(compass_16)))\n\n# Speed category order for proper stacking\nspeed_order = [\"0-3 m/s\", \"3-6 m/s\", \"6-9 m/s\", \"9-12 m/s\", \"12-15 m/s\", \"15+ m/s\"]\ncounts[\"speed_bin\"] = pd.Categorical(counts[\"speed_bin\"], categories=speed_order, ordered=True)\ncounts = counts.sort_values([\"direction_bin\", \"speed_bin\"])\n\n# Imprint palette, canonical positions 1-6 in order\ncolors = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\"]\n\n# Create wind rose using polar bar chart\n# Distinctive lets-plot feature: per-segment tooltips surfaced in the interactive\n# HTML export (compass direction, speed band, and exact frequency on hover).\nbar_tooltips = (\n    layer_tooltips()\n    .title(\"@direction_label\")\n    .line(\"@speed_bin: @{frequency}%\")\n    .format(field=\"@frequency\", format=\".1f\")\n)\n\nplot = (\n    ggplot(counts, aes(x=\"direction\", y=\"frequency\", fill=\"speed_bin\"))\n    + geom_bar(stat=\"identity\", width=0.9, position=\"stack\", tooltips=bar_tooltips)\n    + coord_polar(start=0, direction=1)\n    + scale_x_continuous(\n        breaks=list(range(0, 16, 2)),\n        labels=[\"N\", \"NE\", \"E\", \"SE\", \"S\", \"SW\", \"W\", \"NW\"],\n        limits=[-0.5, 15.5],\n        expand=[0, 0],\n    )\n    + scale_y_continuous(expand=[0, 0])\n    + scale_fill_manual(values=colors, name=\"Wind Speed\")\n    + labs(title=\"windrose-basic · python · letsplot · anyplot.ai\", x=\"\", y=\"Frequency (%)\")\n    + theme_minimal()\n    + theme(\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG),\n        panel_grid_major=element_line(color=INK_SOFT, size=0.3),\n        panel_grid_minor=element_line(color=INK_SOFT, size=0.2),\n        plot_title=element_text(size=16, color=INK, hjust=0.5),\n        axis_text=element_text(size=10, color=INK_SOFT),\n        axis_title_y=element_text(size=12, color=INK),\n        legend_title=element_text(size=12, color=INK),\n        legend_text=element_text(size=10, color=INK_SOFT),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        legend_position=\"right\",\n        legend_key_size=18,\n        legend_key_spacing_y=6,\n        legend_margin=10,\n    )\n    + ggsize(600, 600)\n)\n\n# Save as PNG and HTML (scale=4 to get 2400×2400 px)\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\n\n# coord_polar()'s layout pass leaves a transparent margin around the plot when a\n# title is present; flatten it onto the theme background so PNG edges are opaque.\nimg = Image.open(f\"plot-{THEME}.png\").convert(\"RGBA\")\nbg = Image.new(\"RGBA\", img.size, (*PAGE_BG_RGB, 255))\nImage.alpha_composite(bg, img).convert(\"RGB\").save(f\"plot-{THEME}.png\")\n\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}