{"spec_id":"band-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nband-basic: Basic Band Plot\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 86/100 | Updated: 2026-05-29\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent self-import: script named 'pygal.py' would shadow the real package\n_here = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p or \".\") != _here]\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens (Imprint palette — 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# Series order: band=green(0), center=blue(1) for contrast against green, wilting=red(2) semantic anchor\nIMPRINT_PALETTE = (\"#009E73\", \"#4467A3\", \"#AE3030\", \"#C475FD\", \"#BD8233\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\n# Data — soil moisture sensor readings with 95% confidence interval\nnp.random.seed(42)\nn_points = 80\nhours = np.linspace(0, 48, n_points)\n\n# Realistic soil moisture pattern: starts high after rain, dips during dry spell,\n# partially recovers at night, then drops before a second rain event\nbase_trend = 38 - 0.3 * hours + 4.0 * np.sin(2 * np.pi * hours / 24) + 8.0 * np.exp(-((hours - 40) ** 2) / 8)\nnoise = np.random.randn(n_points) * 0.6\ny_raw = base_trend + noise\n\n# Smooth with convolution, preserving array length with edge padding\nkernel = np.ones(7) / 7\ny_smooth = np.convolve(y_raw, kernel, mode=\"valid\")\npad_left = (n_points - len(y_smooth)) // 2\npad_right = n_points - len(y_smooth) - pad_left\ny_center = np.concatenate([np.full(pad_left, y_smooth[0]), y_smooth, np.full(pad_right, y_smooth[-1])])\n\n# Confidence interval: wider during dry midday, narrower after rain\nuncertainty = 1.2 + 0.8 * np.sin(2 * np.pi * hours / 24) ** 2 + 0.04 * hours\ny_lower = y_center - uncertainty\ny_upper = y_center + uncertainty\n\n# Y-axis tick grid at clean 4% intervals\ny_lo = 4 * (int(min(y_lower)) // 4)\ny_hi = 4 * (int(max(y_upper)) // 4 + 1)\ny_label_values = list(range(y_lo, y_hi + 1, 4))\n\n# Custom style — Imprint palette, theme-adaptive chrome\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=IMPRINT_PALETTE,\n    opacity=\".25\",\n    opacity_hover=\".40\",\n    stroke_opacity=\"1\",\n    stroke_opacity_hover=\"1\",\n    stroke_width=2.5,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    value_colors=(\"transparent\",),\n    tooltip_font_size=32,\n    font_family='Helvetica, Arial, \"DejaVu Sans\", sans-serif',\n)\n\ntitle = \"band-basic · python · pygal · anyplot.ai\"\n\nchart = pygal.XY(\n    style=custom_style,\n    width=3200,\n    height=1800,\n    explicit_size=True,\n    title=title,\n    x_title=\"Time (hours)\",\n    y_title=\"Soil Moisture (%)\",\n    show_dots=False,\n    show_x_guides=False,\n    show_y_guides=True,\n    fill=True,\n    stroke=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=3,\n    legend_box_size=28,\n    truncate_legend=-1,\n    x_label_rotation=0,\n    range=(y_lo - 1, y_hi + 1),\n    y_labels=y_label_values,\n    x_labels=[0, 6, 12, 18, 24, 30, 36, 42, 48],\n    x_labels_major=[0, 12, 24, 36, 48],\n    show_minor_x_labels=True,\n    show_minor_y_labels=False,\n    print_values=False,\n    x_value_formatter=lambda x: f\"{x:.0f}h\",\n    value_formatter=lambda x: f\"{x:.1f}%\",\n    tooltip_border_radius=8,\n    margin_top=30,\n    margin_bottom=50,\n    margin_left=30,\n    margin_right=50,\n    spacing=18,\n)\n\n\n# Band as closed polygon: upper boundary forward, then lower boundary reversed\nband_polygon = [(float(h), float(y)) for h, y in zip(hours, y_upper, strict=True)]\nfor h, y in zip(reversed(hours), reversed(y_lower), strict=True):\n    band_polygon.append((float(h), float(y)))\n\nchart.add(\"95% Confidence Band\", band_polygon, stroke_style={\"width\": 0.5}, show_dots=False)\n\n# Central trend line — blue (palette pos 1) for high contrast against green band, bold stroke\ncenter_data = [(float(h), float(y)) for h, y in zip(hours, y_center, strict=True)]\nchart.add(\n    \"Sensor Mean\",\n    center_data,\n    fill=False,\n    stroke=True,\n    dots_size=0,\n    stroke_style={\"width\": 10, \"linecap\": \"round\", \"linejoin\": \"round\"},\n)\n\n# Wilting point reference — semantic red for danger threshold\nchart.add(\n    \"Wilting Point (25%)\",\n    [(0.0, 25.0), (48.0, 25.0)],\n    fill=False,\n    stroke=True,\n    dots_size=0,\n    formatter=lambda x: f\"{x:.0f}%\",\n    stroke_style={\"width\": 5, \"dasharray\": \"16,10\", \"linecap\": \"round\"},\n)\n\n# Save PNG and interactive HTML\nchart.render_to_png(f\"plot-{THEME}.png\")\nwith open(f\"plot-{THEME}.html\", \"wb\") as f:\n    f.write(chart.render())\n"}