{"spec_id":"heatmap-rainflow","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nheatmap-rainflow: Rainflow Counting Matrix for Fatigue Analysis\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-06-02\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 (see prompts/default-style-guide.md \"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\"\n\n# Imprint sequential colormap — single-polarity continuous data (cycle counts)\nIMPRINT_SEQ = [\"#009E73\", \"#4467A3\"]\n\n# Data — synthetic rainflow counting matrix for a steel component under variable-amplitude loading\nnp.random.seed(42)\n\nn_amp_bins = 20\nn_mean_bins = 20\namplitude_edges = np.linspace(25, 500, n_amp_bins + 1)\nmean_edges = np.linspace(-200, 200, n_mean_bins + 1)\namplitude_centers = (amplitude_edges[:-1] + amplitude_edges[1:]) / 2\nmean_centers = (mean_edges[:-1] + mean_edges[1:]) / 2\n\n# Build count matrix: low amplitude cycles dominate, counts decay with amplitude\namp_grid, mean_grid = np.meshgrid(amplitude_centers, mean_centers, indexing=\"ij\")\n\n# Base distribution: exponential decay in amplitude, Gaussian in mean\nbase_counts = 5000 * np.exp(-amp_grid / 120) * np.exp(-0.5 * (mean_grid / 100) ** 2)\n\n# Secondary cluster at moderate amplitude / slight positive mean (dominant load cycle)\ncluster = 800 * np.exp(-0.5 * ((amp_grid - 175) / 50) ** 2 - 0.5 * ((mean_grid - 50) / 40) ** 2)\nraw_counts = base_counts + cluster\n\n# Add noise and round to integers\nraw_counts += np.random.exponential(scale=5, size=raw_counts.shape)\ncycle_counts = np.round(raw_counts).astype(int)\ncycle_counts = np.clip(cycle_counts, 0, None)\n\n# Sparsify high-amplitude region (fewer cycles at high stress range)\nmask = np.random.rand(*cycle_counts.shape) < 0.3\ncycle_counts[(amp_grid > 350) & mask] = 0\n\n# Convert to long-form DataFrame (drop zero-count bins — PAGE_BG shows through as zero indicator)\nrows = []\nfor i, amp in enumerate(amplitude_centers):\n    for j, mean_val in enumerate(mean_centers):\n        count = int(cycle_counts[i, j])\n        if count > 0:\n            rows.append(\n                {\n                    \"Amplitude (MPa)\": int(round(amp)),\n                    \"Mean Stress (MPa)\": int(round(mean_val)),\n                    \"Cycle Count\": count,\n                    \"Log Count\": float(np.log10(max(count, 1))),\n                }\n            )\n\ndf = pd.DataFrame(rows)\n\n# Sorted tick values for axes (every other bin center to avoid crowding)\namp_sorted = sorted(df[\"Amplitude (MPa)\"].unique().tolist())\nmean_sorted = sorted(df[\"Mean Stress (MPa)\"].unique().tolist())\n\n# Plot — rainflow heatmap with Imprint sequential colormap, log-scaled color\ntitle = \"heatmap-rainflow · python · altair · anyplot.ai\"\n\nheatmap = (\n    alt.Chart(df)\n    .mark_rect(cornerRadius=1)\n    .encode(\n        x=alt.X(\n            \"Mean Stress (MPa):O\",\n            title=\"Mean Stress (MPa)\",\n            sort=mean_sorted,\n            axis=alt.Axis(labelAngle=-45, labelPadding=6, titlePadding=12, values=mean_sorted[::2]),\n        ),\n        y=alt.Y(\n            \"Amplitude (MPa):O\",\n            title=\"Stress Amplitude (MPa)\",\n            sort=sorted(amp_sorted, reverse=True),\n            axis=alt.Axis(labelPadding=6, titlePadding=12, values=amp_sorted[::2]),\n        ),\n        color=alt.Color(\n            \"Log Count:Q\",\n            scale=alt.Scale(range=IMPRINT_SEQ),\n            legend=alt.Legend(\n                title=\"Cycle Count\",\n                titleFontSize=10,\n                labelFontSize=10,\n                gradientLength=220,\n                gradientThickness=15,\n                orient=\"right\",\n                titlePadding=8,\n                offset=12,\n                labelExpr=(\n                    \"pow(10, datum.value) < 10 ? \"\n                    \"format(pow(10, datum.value), '.0f') : \"\n                    \"format(pow(10, datum.value), ',.0f')\"\n                ),\n            ),\n        ),\n        tooltip=[\n            alt.Tooltip(\"Amplitude (MPa):O\", title=\"Amplitude\"),\n            alt.Tooltip(\"Mean Stress (MPa):O\", title=\"Mean Stress\"),\n            alt.Tooltip(\"Cycle Count:Q\", title=\"Cycles\", format=\",\"),\n        ],\n    )\n)\n\n# Style and layout — square canvas: inner view 500×460, scale_factor=4.0 → pads to 2400×2400\nchart = (\n    heatmap.properties(\n        width=478,\n        height=506,\n        background=PAGE_BG,\n        title=alt.Title(\n            title,\n            subtitle=\"Rainflow cycle counting matrix — variable-amplitude fatigue loading on steel\",\n            fontSize=16,\n            subtitleFontSize=12,\n            color=INK,\n            subtitleColor=INK_SOFT,\n            anchor=\"start\",\n            offset=12,\n        ),\n        padding={\"left\": 0, \"right\": 0, \"top\": 0, \"bottom\": 0},\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_axis(\n        grid=False, domain=False, ticks=False, labelColor=INK_SOFT, titleColor=INK, labelFontSize=10, titleFontSize=12\n    )\n    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)\n)\n\n# Save PNG with theme suffix; pad canvas to exactly 2400×2400 (square Imprint target)\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\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}×{_h}, exceeds target {TW}×{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\n# Save interactive HTML\nchart.save(f\"plot-{THEME}.html\")\n"}