Skip to content

Commit 779dd58

Browse files
feat(highcharts): implement heatmap-rainflow (#4482)
## Implementation: `heatmap-rainflow` - highcharts Implements the **highcharts** version of `heatmap-rainflow`. **File:** `plots/heatmap-rainflow/implementations/highcharts.py` **Parent Issue:** #4465 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/22595466105)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 84ef45b commit 779dd58

2 files changed

Lines changed: 480 additions & 0 deletions

File tree

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
""" pyplots.ai
2+
heatmap-rainflow: Rainflow Counting Matrix for Fatigue Analysis
3+
Library: highcharts unknown | Python 3.14.3
4+
Quality: 90/100 | Updated: 2026-03-06
5+
"""
6+
7+
import tempfile
8+
import time
9+
import urllib.request
10+
from pathlib import Path
11+
12+
import numpy as np
13+
from highcharts_core.chart import Chart
14+
from highcharts_core.options import HighchartsOptions
15+
from selenium import webdriver
16+
from selenium.webdriver.chrome.options import Options
17+
18+
19+
# Data - Simulated rainflow counting matrix from variable-amplitude loading
20+
np.random.seed(42)
21+
22+
n_amp_bins = 20
23+
n_mean_bins = 20
24+
25+
# Bin centers in MPa
26+
amplitude_bins = np.linspace(10, 200, n_amp_bins)
27+
mean_bins = np.linspace(-50, 250, n_mean_bins)
28+
29+
# Generate realistic rainflow matrix
30+
# Most cycles at low amplitude, centered around mean ~100 MPa
31+
amp_grid, mean_grid = np.meshgrid(amplitude_bins, mean_bins, indexing="ij")
32+
33+
# Exponential decay with amplitude (low amplitude = many cycles)
34+
amp_factor = np.exp(-0.025 * amp_grid)
35+
36+
# Gaussian distribution around mean ~100 MPa
37+
mean_factor = np.exp(-0.5 * ((mean_grid - 100) / 60) ** 2)
38+
39+
# Combined cycle counts
40+
raw_counts = amp_factor * mean_factor * 5000
41+
raw_counts += np.random.exponential(scale=raw_counts * 0.15 + 1)
42+
cycle_counts = np.round(raw_counts).astype(int)
43+
cycle_counts = np.clip(cycle_counts, 0, None)
44+
45+
# Set very low counts to zero for sparsity (realistic for high-amplitude regions)
46+
cycle_counts[cycle_counts < 3] = 0
47+
48+
# Amplitude labels (y-axis) and mean labels (x-axis)
49+
amp_labels = [f"{v:.0f}" for v in amplitude_bins]
50+
mean_labels = [f"{v:.0f}" for v in mean_bins]
51+
52+
# Build heatmap data: [x_index (mean), y_index (amplitude), value or None]
53+
heatmap_data = []
54+
max_count = 0
55+
for y_idx in range(n_amp_bins):
56+
for x_idx in range(n_mean_bins):
57+
val = int(cycle_counts[y_idx, x_idx])
58+
if val > max_count:
59+
max_count = val
60+
heatmap_data.append([x_idx, y_idx, val if val > 0 else None])
61+
62+
# Build chart using highcharts-core Python wrapper
63+
chart = Chart(container="container")
64+
chart.options = HighchartsOptions.from_dict(
65+
{
66+
"chart": {
67+
"type": "heatmap",
68+
"width": 4800,
69+
"height": 2700,
70+
"backgroundColor": "#fafafa",
71+
"marginTop": 180,
72+
"marginBottom": 200,
73+
"marginRight": 380,
74+
"marginLeft": 320,
75+
"style": {"fontFamily": "'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"},
76+
},
77+
"title": {
78+
"text": "heatmap-rainflow \u00b7 highcharts \u00b7 pyplots.ai",
79+
"style": {"fontSize": "52px", "fontWeight": "600", "color": "#2c3e50"},
80+
"y": 30,
81+
},
82+
"subtitle": {
83+
"text": "Rainflow cycle counting matrix \u2014 low-amplitude cycles near 100 MPa mean dominate the fatigue spectrum",
84+
"style": {"fontSize": "30px", "fontWeight": "normal", "color": "#7f8c8d"},
85+
"y": 80,
86+
},
87+
"xAxis": {
88+
"categories": mean_labels,
89+
"title": {
90+
"text": "Cycle Mean (MPa)",
91+
"style": {"fontSize": "34px", "fontWeight": "600", "color": "#34495e"},
92+
"margin": 20,
93+
},
94+
"labels": {"style": {"fontSize": "28px", "color": "#34495e"}, "rotation": 315, "y": 30},
95+
"lineWidth": 0,
96+
"tickLength": 0,
97+
},
98+
"yAxis": {
99+
"categories": amp_labels,
100+
"title": {
101+
"text": "Cycle Amplitude (MPa)",
102+
"style": {"fontSize": "34px", "fontWeight": "600", "color": "#34495e"},
103+
"margin": 20,
104+
},
105+
"labels": {"style": {"fontSize": "28px", "color": "#34495e"}},
106+
"reversed": False,
107+
"lineWidth": 0,
108+
"gridLineWidth": 0,
109+
},
110+
"colorAxis": {
111+
"min": 1,
112+
"max": int(max_count),
113+
"type": "logarithmic",
114+
"stops": [
115+
[0, "#440154"],
116+
[0.12, "#482878"],
117+
[0.25, "#3e4989"],
118+
[0.37, "#31688e"],
119+
[0.50, "#26828e"],
120+
[0.62, "#1f9e89"],
121+
[0.75, "#35b779"],
122+
[0.87, "#6ece58"],
123+
[1, "#fde725"],
124+
],
125+
"labels": {"style": {"fontSize": "28px", "color": "#34495e"}},
126+
},
127+
"legend": {
128+
"title": {"text": "Cycle Count", "style": {"fontSize": "28px", "fontWeight": "600", "color": "#34495e"}},
129+
"align": "right",
130+
"layout": "vertical",
131+
"verticalAlign": "middle",
132+
"symbolHeight": 900,
133+
"symbolWidth": 36,
134+
"itemStyle": {"fontSize": "24px", "color": "#34495e"},
135+
"x": -40,
136+
"margin": 40,
137+
},
138+
"tooltip": {
139+
"style": {"fontSize": "30px"},
140+
"headerFormat": "",
141+
"pointFormat": (
142+
"Amplitude: <b>{series.yAxis.categories.(point.y)} MPa</b><br>"
143+
"Mean: <b>{series.xAxis.categories.(point.x)} MPa</b><br>"
144+
"Cycles: <b>{point.value}</b>"
145+
),
146+
},
147+
"credits": {"enabled": False},
148+
"plotOptions": {"heatmap": {"colsize": 1, "rowsize": 1}},
149+
"series": [
150+
{
151+
"type": "heatmap",
152+
"name": "Cycle Count",
153+
"data": heatmap_data,
154+
"borderWidth": 2,
155+
"borderColor": "#fafafa",
156+
"nullColor": "#f0f0f0",
157+
}
158+
],
159+
}
160+
)
161+
162+
# Generate chart JS literal via highcharts-core wrapper
163+
js_literal = chart.to_js_literal()
164+
165+
# Download Highcharts JS and heatmap module with retry
166+
urls = {
167+
"highcharts": "https://cdn.jsdelivr.net/npm/highcharts/highcharts.js",
168+
"heatmap": "https://cdn.jsdelivr.net/npm/highcharts/modules/heatmap.js",
169+
}
170+
scripts = {}
171+
for name, url in urls.items():
172+
for attempt in range(3):
173+
try:
174+
with urllib.request.urlopen(url, timeout=30) as response:
175+
scripts[name] = response.read().decode("utf-8")
176+
break
177+
except urllib.error.HTTPError:
178+
time.sleep(2 * (attempt + 1))
179+
else:
180+
raise RuntimeError(f"Failed to download {url}")
181+
182+
highcharts_js = scripts["highcharts"]
183+
heatmap_js = scripts["heatmap"]
184+
185+
# Generate HTML with inline scripts and renderer annotation for data storytelling
186+
html_content = f"""<!DOCTYPE html>
187+
<html>
188+
<head>
189+
<meta charset="utf-8">
190+
<script>{highcharts_js}</script>
191+
<script>{heatmap_js}</script>
192+
</head>
193+
<body style="margin:0; padding:0; overflow:hidden; background:#fafafa;">
194+
<div id="container" style="width:4800px; height:2700px;"></div>
195+
<script>
196+
{js_literal}
197+
</script>
198+
<script>
199+
// Add annotation highlighting the dominant fatigue region
200+
// (separate DOMContentLoaded ensures chart is created first)
201+
document.addEventListener('DOMContentLoaded', function() {{
202+
var ch = Highcharts.charts[Highcharts.charts.length - 1];
203+
if (ch) {{
204+
ch.renderer.label(
205+
'\\u25B6 Peak region: low-amplitude cycles near<br>' +
206+
'\\u2003 100 MPa mean stress dominate fatigue damage',
207+
ch.plotLeft + ch.plotWidth * 0.55,
208+
ch.plotTop + ch.plotHeight * 0.78
209+
).css({{
210+
fontSize: '28px',
211+
color: '#333',
212+
fontStyle: 'italic',
213+
lineHeight: '40px'
214+
}}).attr({{
215+
fill: 'rgba(255, 255, 255, 0.93)',
216+
stroke: '#888',
217+
'stroke-width': 1.5,
218+
padding: 18,
219+
r: 8,
220+
zIndex: 5
221+
}}).add();
222+
}}
223+
}});
224+
</script>
225+
</body>
226+
</html>"""
227+
228+
# Save HTML for interactive version
229+
with open("plot.html", "w", encoding="utf-8") as f:
230+
f.write(html_content)
231+
232+
# Take screenshot using headless Chrome
233+
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False, encoding="utf-8") as f:
234+
f.write(html_content)
235+
temp_path = f.name
236+
237+
chrome_options = Options()
238+
chrome_options.add_argument("--headless=new")
239+
chrome_options.add_argument("--no-sandbox")
240+
chrome_options.add_argument("--disable-dev-shm-usage")
241+
chrome_options.add_argument("--disable-gpu")
242+
chrome_options.add_argument("--window-size=4800,2840")
243+
chrome_options.add_argument("--force-device-scale-factor=1")
244+
chrome_options.add_argument("--hide-scrollbars")
245+
246+
driver = webdriver.Chrome(options=chrome_options)
247+
driver.set_window_size(4800, 2840)
248+
driver.get(f"file://{temp_path}")
249+
time.sleep(5)
250+
driver.save_screenshot("plot.png")
251+
driver.quit()
252+
253+
Path(temp_path).unlink()

0 commit comments

Comments
 (0)