Coverage for src/unit_cooler/metrics/webapi/page.py: 11%
249 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-07-23 14:35 +0000
« prev ^ index » next coverage.py v7.9.1, created at 2025-07-23 14:35 +0000
1"""
2室外機冷却システムメトリクス表示ページ
4冷却モード、Duty比、環境要因の統計情報とグラフを表示するWebページを提供します。
5"""
7from __future__ import annotations
9import datetime
10import io
11import json
12import logging
13import zoneinfo
15import flask
16import my_lib.webapp.config
17from PIL import Image, ImageDraw
19from unit_cooler.metrics.collector import get_metrics_collector
21blueprint = flask.Blueprint("metrics", __name__, url_prefix=my_lib.webapp.config.URL_PREFIX)
24@blueprint.route("/api/metrics", methods=["GET"])
25def metrics_view():
26 """メトリクスダッシュボードページを表示"""
27 try:
28 # 設定からメトリクスデータパスを取得
29 config = flask.current_app.config["CONFIG"]
30 metrics_data_path = config.get("actuator", {}).get("metrics", {}).get("data")
32 # データベースファイルの存在確認
33 if not metrics_data_path:
34 return flask.Response(
35 "<html><body><h1>メトリクス設定が見つかりません</h1>"
36 "<p>config.yamlでactuator.metricsセクションが設定されていません。</p></body></html>",
37 mimetype="text/html",
38 status=503,
39 )
41 from pathlib import Path
43 db_path = Path(metrics_data_path)
44 if not db_path.exists():
45 return flask.Response(
46 f"<html><body><h1>メトリクスデータベースが見つかりません</h1>"
47 f"<p>データベースファイル: {db_path}</p>"
48 f"<p>システムが十分に動作してからメトリクスが生成されます。</p></body></html>",
49 mimetype="text/html",
50 status=503,
51 )
53 # メトリクス収集器を取得
54 collector = get_metrics_collector(metrics_data_path)
56 # 全期間のデータを取得(制限なし)
57 end_time = datetime.datetime.now(zoneinfo.ZoneInfo("Asia/Tokyo"))
58 start_time = None # 無制限
60 minute_data = collector.get_minute_data(start_time, end_time)
61 hourly_data = collector.get_hourly_data(start_time, end_time)
62 error_data = collector.get_error_data(start_time, end_time)
64 # 統計データを生成
65 stats = generate_statistics(minute_data, hourly_data, error_data)
67 # データ期間情報を取得
68 period_info = get_data_period_info(minute_data, hourly_data)
70 # HTMLを生成
71 html_content = generate_metrics_html(stats, minute_data, hourly_data, period_info)
73 return flask.Response(html_content, mimetype="text/html")
75 except Exception as e:
76 logging.exception("メトリクス表示の生成エラー")
77 return flask.Response(f"エラー: {e!s}", mimetype="text/plain", status=500)
80@blueprint.route("/favicon.ico", methods=["GET"])
81def favicon():
82 """動的生成された室外機冷却システムメトリクス用favicon.icoを返す"""
83 try:
84 # 室外機冷却システムメトリクスアイコンを生成
85 img = generate_cooler_metrics_icon()
87 # ICO形式で出力
88 output = io.BytesIO()
89 img.save(output, format="ICO", sizes=[(32, 32)])
90 output.seek(0)
92 return flask.Response(
93 output.getvalue(),
94 mimetype="image/x-icon",
95 headers={
96 "Cache-Control": "public, max-age=3600", # 1時間キャッシュ
97 "Content-Type": "image/x-icon",
98 },
99 )
100 except Exception:
101 logging.exception("favicon生成エラー")
102 return flask.Response("", status=500)
105def generate_cooler_metrics_icon():
106 """室外機冷却システムメトリクス用のアイコンを動的生成(アンチエイリアス対応)"""
107 # アンチエイリアスのため4倍サイズで描画してから縮小
108 scale = 4
109 size = 32
110 large_size = size * scale
112 # 大きなサイズで描画
113 img = Image.new("RGBA", (large_size, large_size), (0, 0, 0, 0))
114 draw = ImageDraw.Draw(img)
116 # 背景円(冷却システムらしい青色)
117 margin = 2 * scale
118 draw.ellipse(
119 [margin, margin, large_size - margin, large_size - margin],
120 fill=(52, 152, 219, 255),
121 outline=(41, 128, 185, 255),
122 width=2 * scale,
123 )
125 # 冷却アイコン(雪の結晶風)
126 center_x, center_y = large_size // 2, large_size // 2
128 # 雪の結晶の線
129 for angle in [0, 60, 120]:
130 import math
132 rad = math.radians(angle)
133 x1 = center_x + 8 * scale * math.cos(rad)
134 y1 = center_y + 8 * scale * math.sin(rad)
135 x2 = center_x - 8 * scale * math.cos(rad)
136 y2 = center_y - 8 * scale * math.sin(rad)
137 draw.line([(x1, y1), (x2, y2)], fill=(255, 255, 255, 255), width=2 * scale)
139 # 枝
140 for branch_pos in [0.6, -0.6]:
141 bx = center_x + branch_pos * 8 * scale * math.cos(rad)
142 by = center_y + branch_pos * 8 * scale * math.sin(rad)
143 for branch_angle in [30, -30]:
144 branch_rad = math.radians(angle + branch_angle)
145 bx2 = bx + 3 * scale * math.cos(branch_rad)
146 by2 = by + 3 * scale * math.sin(branch_rad)
147 draw.line([(bx, by), (bx2, by2)], fill=(255, 255, 255, 255), width=1 * scale)
149 # 中心の点
150 draw.ellipse(
151 [center_x - 2 * scale, center_y - 2 * scale, center_x + 2 * scale, center_y + 2 * scale],
152 fill=(255, 255, 255, 255),
153 )
155 # 32x32に縮小してアンチエイリアス効果を得る
156 return img.resize((size, size), Image.LANCZOS)
159def get_data_period_info(minute_data: list[dict], hourly_data: list[dict]) -> dict:
160 """データ期間の情報を取得"""
161 all_data = minute_data + hourly_data
162 if not all_data:
163 return {"start_date": None, "end_date": None, "total_days": 0, "period_text": "データなし"}
165 # タイムスタンプを解析
166 timestamps = []
167 for data in all_data:
168 if data.get("timestamp"):
169 try:
170 if isinstance(data["timestamp"], str):
171 if "T" in data["timestamp"]:
172 dt = datetime.datetime.fromisoformat(data["timestamp"].replace("Z", "+00:00"))
173 else:
174 dt = datetime.datetime.strptime(data["timestamp"], "%Y-%m-%d %H:%M:%S").replace(
175 tzinfo=zoneinfo.ZoneInfo("Asia/Tokyo")
176 )
177 else:
178 dt = data["timestamp"]
179 timestamps.append(dt)
180 except Exception:
181 logging.debug("Failed to parse timestamp: %s", data["timestamp"])
183 if not timestamps:
184 return {
185 "start_date": None,
186 "end_date": None,
187 "total_days": 0,
188 "period_text": "タイムスタンプ情報なし",
189 }
191 start_date = min(timestamps)
192 end_date = max(timestamps)
193 total_days = (end_date - start_date).days + 1
195 # 期間テキストを生成
196 start_str = start_date.strftime("%Y年%m月%d日")
197 period_text = f"過去{total_days}日間({start_str}〜)の冷却システム統計"
199 return {
200 "start_date": start_date,
201 "end_date": end_date,
202 "total_days": total_days,
203 "period_text": period_text,
204 }
207def generate_statistics(minute_data: list[dict], hourly_data: list[dict], error_data: list[dict]) -> dict:
208 """メトリクスデータから統計情報を生成"""
209 if not minute_data and not hourly_data:
210 return {
211 "total_days": 0,
212 "cooling_mode_avg": None,
213 "duty_ratio_avg": None,
214 "valve_operations_total": 0,
215 "temperature_avg": None,
216 "humidity_avg": None,
217 "error_total": len(error_data),
218 "data_points": 0,
219 }
221 # 有効なデータのみでフィルタリング
222 cooling_modes = [d["cooling_mode"] for d in minute_data if d.get("cooling_mode") is not None]
223 duty_ratios = [d["duty_ratio"] for d in minute_data if d.get("duty_ratio") is not None]
224 temperatures = [d["temperature"] for d in minute_data if d.get("temperature") is not None]
225 humidities = [d["humidity"] for d in minute_data if d.get("humidity") is not None]
227 valve_operations_total = sum(d.get("valve_operations", 0) for d in hourly_data)
229 # 日数を計算(タイムスタンプから一意の日付を抽出)
230 unique_dates = set()
231 for d in minute_data + hourly_data:
232 if d.get("timestamp"):
233 try:
234 date_part = (
235 d["timestamp"].split("T")[0]
236 if "T" in str(d["timestamp"])
237 else str(d["timestamp"]).split()[0]
238 )
239 unique_dates.add(date_part)
240 except Exception:
241 logging.debug("Failed to parse timestamp for date calculation")
243 return {
244 "total_days": len(unique_dates),
245 "cooling_mode_avg": sum(cooling_modes) / len(cooling_modes) if cooling_modes else None,
246 "duty_ratio_avg": sum(duty_ratios) / len(duty_ratios) if duty_ratios else None,
247 "valve_operations_total": valve_operations_total,
248 "temperature_avg": sum(temperatures) / len(temperatures) if temperatures else None,
249 "humidity_avg": sum(humidities) / len(humidities) if humidities else None,
250 "error_total": len(error_data),
251 "data_points": len(minute_data),
252 }
255def calculate_correlation(x_values: list, y_values: list) -> float:
256 """ピアソンの相関係数を計算"""
257 if not x_values or not y_values or len(x_values) != len(y_values):
258 return 0.0
260 # None値を除外
261 valid_pairs = [
262 (x, y) for x, y in zip(x_values, y_values, strict=False) if x is not None and y is not None
263 ]
264 if len(valid_pairs) < 2:
265 return 0.0
267 x_vals, y_vals = zip(*valid_pairs, strict=False)
268 n = len(x_vals)
270 # 平均を計算
271 x_mean = sum(x_vals) / n
272 y_mean = sum(y_vals) / n
274 # 分子と分母を計算
275 numerator = sum((x - x_mean) * (y - y_mean) for x, y in zip(x_vals, y_vals, strict=False))
276 x_variance = sum((x - x_mean) ** 2 for x in x_vals)
277 y_variance = sum((y - y_mean) ** 2 for y in y_vals)
279 denominator = (x_variance * y_variance) ** 0.5
281 if denominator == 0:
282 return 0.0
284 return numerator / denominator
287def calculate_boxplot_stats(values: list) -> dict:
288 """箱ヒゲ図用の統計データを計算"""
289 if not values:
290 return {"min": 0, "q1": 0, "median": 0, "q3": 0, "max": 0, "outliers": []}
292 values_sorted = sorted(values)
293 n = len(values_sorted)
295 # 四分位数を計算
296 q1_idx = n // 4
297 median_idx = n // 2
298 q3_idx = 3 * n // 4
300 q1 = values_sorted[q1_idx]
301 median = values_sorted[median_idx]
302 q3 = values_sorted[q3_idx]
304 # IQRと外れ値を計算
305 iqr = q3 - q1
306 lower_bound = q1 - 1.5 * iqr
307 upper_bound = q3 + 1.5 * iqr
309 # 外れ値を特定
310 outliers = [v for v in values_sorted if v < lower_bound or v > upper_bound]
312 # 外れ値を除いた最小値・最大値
313 non_outliers = [v for v in values_sorted if lower_bound <= v <= upper_bound]
314 min_val = min(non_outliers) if non_outliers else values_sorted[0]
315 max_val = max(non_outliers) if non_outliers else values_sorted[-1]
317 return {"min": min_val, "q1": q1, "median": median, "q3": q3, "max": max_val, "outliers": outliers}
320def _extract_hour_from_timestamp(timestamp) -> int | None:
321 """タイムスタンプから時間を抽出"""
322 try:
323 if isinstance(timestamp, str):
324 if "T" in timestamp:
325 time_part = timestamp.split("T")[1].split(":")[0]
326 else:
327 time_part = timestamp.split()[1].split(":")[0]
328 return int(time_part)
329 else:
330 return timestamp.hour
331 except Exception:
332 logging.debug("Failed to extract hour from timestamp")
333 return None
336def _prepare_hourly_data(minute_data: list[dict], hourly_data: list[dict]) -> tuple:
337 """時間別データを準備"""
338 hourly_cooling_mode = [[] for _ in range(24)]
339 hourly_duty_ratio = [[] for _ in range(24)]
340 hourly_valve_ops = [[] for _ in range(24)]
342 # 分データから時間別に集計
343 for data in minute_data:
344 if data.get("timestamp"):
345 hour = _extract_hour_from_timestamp(data["timestamp"])
346 if hour is not None and 0 <= hour < 24:
347 if data.get("cooling_mode") is not None:
348 hourly_cooling_mode[hour].append(data["cooling_mode"])
349 if data.get("duty_ratio") is not None:
350 hourly_duty_ratio[hour].append(data["duty_ratio"])
352 # 時間データから時間別バルブ操作数を集計
353 for data in hourly_data:
354 if data.get("timestamp") and data.get("valve_operations") is not None:
355 hour = _extract_hour_from_timestamp(data["timestamp"])
356 if hour is not None and 0 <= hour < 24:
357 hourly_valve_ops[hour].append(data["valve_operations"])
359 return hourly_cooling_mode, hourly_duty_ratio, hourly_valve_ops
362def _prepare_timeseries_data(minute_data: list[dict]) -> list[dict]:
363 """時系列データを準備(過去100日分)"""
364 timeseries_data = []
366 # 過去100日分のデータを取得(144000分)
367 recent_data = minute_data[-144000:] if len(minute_data) > 144000 else minute_data
369 # 時系列表示のため、データを古い順(昇順)に並び替え
370 recent_data = list(reversed(recent_data))
372 # データポイント数が多い場合は平均化して処理
373 target_points = 1000 # 目標ポイント数
374 if len(recent_data) > target_points:
375 # データを等間隔に分割して平均化
376 chunk_size = len(recent_data) // target_points
377 averaged_data = []
379 for i in range(0, len(recent_data), chunk_size):
380 chunk = recent_data[i : i + chunk_size]
381 if not chunk:
382 continue
384 # チャンクの最初のタイムスタンプを使用
385 base_data = chunk[0]
387 # 数値データの平均を計算
388 avg_data = {
389 "timestamp": base_data.get("timestamp"),
390 "cooling_mode": sum(
391 d.get("cooling_mode") or 0 for d in chunk if d.get("cooling_mode") is not None
392 )
393 / max(1, sum(1 for d in chunk if d.get("cooling_mode") is not None)),
394 "duty_ratio": sum(d.get("duty_ratio") or 0 for d in chunk if d.get("duty_ratio") is not None)
395 / max(1, sum(1 for d in chunk if d.get("duty_ratio") is not None)),
396 "temperature": sum(
397 d.get("temperature") or 0 for d in chunk if d.get("temperature") is not None
398 )
399 / max(1, sum(1 for d in chunk if d.get("temperature") is not None)),
400 "humidity": sum(d.get("humidity") or 0 for d in chunk if d.get("humidity") is not None)
401 / max(1, sum(1 for d in chunk if d.get("humidity") is not None)),
402 "lux": sum(d.get("lux") or 0 for d in chunk if d.get("lux") is not None)
403 / max(1, sum(1 for d in chunk if d.get("lux") is not None)),
404 "solar_radiation": sum(
405 d.get("solar_radiation") or 0 for d in chunk if d.get("solar_radiation") is not None
406 )
407 / max(1, sum(1 for d in chunk if d.get("solar_radiation") is not None)),
408 "rain_amount": sum(
409 d.get("rain_amount") or 0 for d in chunk if d.get("rain_amount") is not None
410 )
411 / max(1, sum(1 for d in chunk if d.get("rain_amount") is not None)),
412 }
413 averaged_data.append(avg_data)
415 recent_data = averaged_data
417 for data in recent_data:
418 if data.get("timestamp"):
419 # タイムスタンプを簡潔な形式に変換(月/日 時:分)
420 timestamp = data["timestamp"]
421 if isinstance(timestamp, str):
422 # ISO形式の場合は datetime に変換
423 try:
424 if "T" in timestamp:
425 timestamp = datetime.datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
426 else:
427 timestamp = datetime.datetime.strptime(timestamp, "%Y-%m-%d %H:%M:%S").replace(
428 tzinfo=zoneinfo.ZoneInfo("Asia/Tokyo")
429 )
430 except Exception:
431 logging.debug("Failed to parse timestamp for time series formatting")
433 # 簡潔な形式にフォーマット
434 if hasattr(timestamp, "strftime"):
435 formatted_timestamp = timestamp.strftime("%m/%d %H:%M")
436 else:
437 formatted_timestamp = str(timestamp)
439 timeseries_data.append(
440 {
441 "timestamp": formatted_timestamp,
442 "cooling_mode": data.get("cooling_mode"),
443 "duty_ratio": data.get("duty_ratio"),
444 "temperature": data.get("temperature"),
445 "humidity": data.get("humidity"),
446 "lux": data.get("lux"),
447 "solar_radiation": data.get("solar_radiation"),
448 "rain_amount": data.get("rain_amount"),
449 }
450 )
451 return timeseries_data
454def _prepare_correlation_data(minute_data: list[dict]) -> dict:
455 """環境要因との相関用データを準備"""
456 return {
457 "cooling_mode": [d.get("cooling_mode") for d in minute_data if d.get("cooling_mode") is not None],
458 "duty_ratio": [d.get("duty_ratio") for d in minute_data if d.get("duty_ratio") is not None],
459 "temperature": [d.get("temperature") for d in minute_data if d.get("temperature") is not None],
460 "humidity": [d.get("humidity") for d in minute_data if d.get("humidity") is not None],
461 "lux": [d.get("lux") for d in minute_data if d.get("lux") is not None],
462 "solar_radiation": [
463 d.get("solar_radiation") for d in minute_data if d.get("solar_radiation") is not None
464 ],
465 "rain_amount": [d.get("rain_amount") for d in minute_data if d.get("rain_amount") is not None],
466 }
469def _prepare_boxplot_data(
470 hourly_cooling_mode: list, hourly_duty_ratio: list, hourly_valve_ops: list
471) -> tuple:
472 """箱ヒゲ図用データを生成"""
473 boxplot_cooling_mode = []
474 boxplot_duty_ratio = []
475 boxplot_valve_ops = []
477 for hour in range(24):
478 boxplot_cooling_mode.append(
479 {"x": f"{hour:02d}:00", "y": calculate_boxplot_stats(hourly_cooling_mode[hour])}
480 )
482 # Duty比をパーセンテージに変換
483 duty_ratio_percent = [d * 100 for d in hourly_duty_ratio[hour] if d is not None]
484 boxplot_duty_ratio.append({"x": f"{hour:02d}:00", "y": calculate_boxplot_stats(duty_ratio_percent)})
486 boxplot_valve_ops.append(
487 {"x": f"{hour:02d}:00", "y": calculate_boxplot_stats(hourly_valve_ops[hour])}
488 )
490 return boxplot_cooling_mode, boxplot_duty_ratio, boxplot_valve_ops
493def prepare_chart_data(minute_data: list[dict], hourly_data: list[dict]) -> dict:
494 """チャート用データを準備"""
495 # 各データ準備を個別の関数で処理
496 hourly_cooling_mode, hourly_duty_ratio, hourly_valve_ops = _prepare_hourly_data(minute_data, hourly_data)
497 timeseries_data = _prepare_timeseries_data(minute_data)
498 correlation_data = _prepare_correlation_data(minute_data)
499 boxplot_cooling_mode, boxplot_duty_ratio, boxplot_valve_ops = _prepare_boxplot_data(
500 hourly_cooling_mode, hourly_duty_ratio, hourly_valve_ops
501 )
503 return {
504 "hourly_cooling_mode": hourly_cooling_mode,
505 "hourly_duty_ratio": hourly_duty_ratio,
506 "hourly_valve_ops": hourly_valve_ops,
507 "boxplot_cooling_mode": boxplot_cooling_mode,
508 "boxplot_duty_ratio": boxplot_duty_ratio,
509 "boxplot_valve_ops": boxplot_valve_ops,
510 "timeseries": timeseries_data,
511 "correlation": correlation_data,
512 }
515def generate_metrics_html(
516 stats: dict, minute_data: list[dict], hourly_data: list[dict], period_info: dict
517) -> str:
518 """Bulma CSSを使用したメトリクスHTMLを生成"""
519 # JavaScript用データを準備
520 chart_data = prepare_chart_data(minute_data, hourly_data)
521 chart_data_json = json.dumps(chart_data)
523 # URL_PREFIXを取得してfaviconパスを構築
524 favicon_path = f"{my_lib.webapp.config.URL_PREFIX}/favicon.ico"
526 return f"""
527<!DOCTYPE html>
528<html>
529<head>
530 <meta charset="utf-8">
531 <meta name="viewport" content="width=device-width, initial-scale=1">
532 <title>室外機冷却システム メトリクス ダッシュボード</title>
533 <link rel="icon" type="image/x-icon" href="{favicon_path}">
534 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
535 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
536 <script src="https://cdn.jsdelivr.net/npm/@sgratzl/chartjs-chart-boxplot"></script>
537 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
538 <style>
539 .metrics-card {{ margin-bottom: 1rem; }}
540 @media (max-width: 768px) {{
541 .metrics-card {{ margin-bottom: 0.75rem; }}
542 }}
543 .stat-number {{ font-size: 2rem; font-weight: bold; }}
544 .chart-container {{ position: relative; height: 350px; margin: 0.5rem 0; }}
545 @media (max-width: 768px) {{
546 .chart-container {{ height: 300px; margin: 0.25rem 0; }}
547 .container.is-fluid {{ padding: 0.25rem !important; }}
548 .section {{ padding: 0.5rem 0.25rem !important; }}
549 .card {{ margin-bottom: 1rem !important; }}
550 .columns {{ margin: 0 !important; }}
551 .column {{ padding: 0.25rem !important; }}
552 }}
553 .japanese-font {{
554 font-family: "Hiragino Sans", "Hiragino Kaku Gothic ProN",
555 "Noto Sans CJK JP", "Yu Gothic", sans-serif;
556 }}
557 .permalink-header {{
558 position: relative;
559 display: inline-block;
560 }}
561 .permalink-icon {{
562 opacity: 0;
563 transition: opacity 0.2s ease-in-out;
564 cursor: pointer;
565 color: #4a90e2;
566 margin-left: 0.5rem;
567 font-size: 0.8em;
568 }}
569 .permalink-header:hover .permalink-icon {{
570 opacity: 1;
571 }}
572 .permalink-icon:hover {{
573 color: #357abd;
574 }}
575 </style>
576</head>
577<body class="japanese-font">
578 <div class="container is-fluid" style="padding: 0.5rem;">
579 <section class="section" style="padding: 1rem 0.5rem;">
580 <div class="container" style="max-width: 100%; padding: 0;">
581 <h1 class="title is-2 has-text-centered">
582 <span class="icon is-large"><i class="fas fa-snowflake"></i></span>
583 室外機冷却システム メトリクス ダッシュボード
584 </h1>
585 <p class="subtitle has-text-centered">{period_info["period_text"]}</p>
587 <!-- 基本統計 -->
588 {generate_basic_stats_section(stats)}
590 <!-- 時間別分布分析 -->
591 {generate_hourly_analysis_section()}
593 <!-- 時系列データ分析 -->
594 {generate_timeseries_section()}
596 <!-- 環境要因相関分析 -->
597 {generate_correlation_section()}
598 </div>
599 </section>
600 </div>
602 <script>
603 const chartData = {chart_data_json};
605 // チャート生成
606 generateHourlyCharts();
607 generateTimeseriesCharts();
608 generateCorrelationCharts();
610 // パーマリンク機能を初期化
611 initializePermalinks();
613 {generate_chart_javascript()}
614 </script>
615</html>
616 """
619def _format_cooling_mode_avg(stats: dict) -> str:
620 """冷却モード平均値をフォーマット"""
621 return "N/A" if stats["cooling_mode_avg"] is None else f"{stats['cooling_mode_avg']:.2f}"
624def _format_duty_ratio_avg(stats: dict) -> str:
625 """Duty比平均値をフォーマット"""
626 return "N/A" if stats["duty_ratio_avg"] is None else f"{stats['duty_ratio_avg'] * 100:.1f}"
629def _format_valve_operations(stats: dict) -> str:
630 """バルブ操作回数をフォーマット"""
631 return f"{stats['valve_operations_total']:,}"
634def generate_basic_stats_section(stats: dict) -> str:
635 """基本統計セクションのHTML生成"""
636 return f"""
637 <div class="section">
638 <h2 class="title is-4 permalink-header" id="basic-stats">
639 <span class="icon"><i class="fas fa-chart-bar"></i></span>
640 基本統計
641 <span class="permalink-icon" onclick="copyPermalink('basic-stats')">
642 <i class="fas fa-link"></i>
643 </span>
644 </h2>
646 <div class="columns">
647 <div class="column">
648 <div class="card metrics-card">
649 <div class="card-header">
650 <p class="card-header-title">システム稼働状況</p>
651 </div>
652 <div class="card-content">
653 <div class="columns is-multiline">
654 <div class="column is-one-third">
655 <div class="has-text-centered">
656 <p class="heading">❄️ 冷却モード平均</p>
657 <p class="stat-number has-text-info">{_format_cooling_mode_avg(stats)}</p>
658 </div>
659 </div>
660 <div class="column is-one-third">
661 <div class="has-text-centered">
662 <p class="heading">⚡ Duty比平均</p>
663 <p class="stat-number has-text-success">
664 {_format_duty_ratio_avg(stats)}%
665 </p>
666 </div>
667 </div>
668 <div class="column is-one-third">
669 <div class="has-text-centered">
670 <p class="heading">🔧 バルブ操作回数</p>
671 <p class="stat-number has-text-warning">
672 {_format_valve_operations(stats)}
673 </p>
674 </div>
675 </div>
676 <div class="column is-one-third">
677 <div class="has-text-centered">
678 <p class="heading">❌ エラー数</p>
679 <p class="stat-number has-text-danger">{stats["error_total"]:,}</p>
680 </div>
681 </div>
682 <div class="column is-one-third">
683 <div class="has-text-centered">
684 <p class="heading">📊 データポイント数</p>
685 <p class="stat-number has-text-primary">{stats["data_points"]:,}</p>
686 </div>
687 </div>
688 <div class="column is-one-third">
689 <div class="has-text-centered">
690 <p class="heading">📅 データ収集日数</p>
691 <p class="stat-number has-text-primary">{stats["total_days"]:,}</p>
692 </div>
693 </div>
694 </div>
695 </div>
696 </div>
697 </div>
698 </div>
699 </div>
700 """
703def generate_hourly_analysis_section() -> str:
704 """時間別分析セクションのHTML生成"""
705 return """
706 <div class="section">
707 <h2 class="title is-4 permalink-header" id="hourly-analysis">
708 <span class="icon"><i class="fas fa-clock"></i></span> 時間別分布分析
709 <span class="permalink-icon" onclick="copyPermalink('hourly-analysis')">
710 <i class="fas fa-link"></i>
711 </span>
712 </h2>
714 <div class="columns">
715 <div class="column">
716 <div class="card metrics-card">
717 <div class="card-header">
718 <p class="card-header-title permalink-header" id="cooling-mode-hourly">
719 ❄️ 冷却モードの時間別分布
720 <span class="permalink-icon" onclick="copyPermalink('cooling-mode-hourly')">
721 <i class="fas fa-link"></i>
722 </span>
723 </p>
724 </div>
725 <div class="card-content">
726 <div class="chart-container">
727 <canvas id="coolingModeHourlyChart"></canvas>
728 </div>
729 </div>
730 </div>
731 </div>
732 </div>
734 <div class="columns">
735 <div class="column">
736 <div class="card metrics-card">
737 <div class="card-header">
738 <p class="card-header-title permalink-header" id="duty-ratio-hourly">
739 ⚡ Duty比の時間別分布
740 <span class="permalink-icon" onclick="copyPermalink('duty-ratio-hourly')">
741 <i class="fas fa-link"></i>
742 </span>
743 </p>
744 </div>
745 <div class="card-content">
746 <div class="chart-container">
747 <canvas id="dutyRatioHourlyChart"></canvas>
748 </div>
749 </div>
750 </div>
751 </div>
752 </div>
754 <div class="columns">
755 <div class="column">
756 <div class="card metrics-card">
757 <div class="card-header">
758 <p class="card-header-title permalink-header" id="valve-ops-hourly">
759 🔧 バルブ操作回数の時間別分布
760 <span class="permalink-icon" onclick="copyPermalink('valve-ops-hourly')">
761 <i class="fas fa-link"></i>
762 </span>
763 </p>
764 </div>
765 <div class="card-content">
766 <div class="chart-container">
767 <canvas id="valveOpsHourlyChart"></canvas>
768 </div>
769 </div>
770 </div>
771 </div>
772 </div>
773 </div>
774 """
777def generate_timeseries_section() -> str:
778 """時系列データ分析セクションのHTML生成"""
779 return """
780 <div class="section">
781 <h2 class="title is-4 permalink-header" id="timeseries">
782 <span class="icon"><i class="fas fa-chart-line"></i></span> 時系列推移分析
783 <span class="permalink-icon" onclick="copyPermalink('timeseries')">
784 <i class="fas fa-link"></i>
785 </span>
786 </h2>
788 <div class="columns">
789 <div class="column">
790 <div class="card metrics-card">
791 <div class="card-header">
792 <p class="card-header-title permalink-header" id="cooling-duty-timeseries">
793 📈 冷却モードとDuty比の時系列推移
794 <span class="permalink-icon" onclick="copyPermalink('cooling-duty-timeseries')">
795 <i class="fas fa-link"></i>
796 </span>
797 </p>
798 </div>
799 <div class="card-content">
800 <div class="chart-container">
801 <canvas id="coolingDutyTimeseriesChart"></canvas>
802 </div>
803 </div>
804 </div>
805 </div>
806 </div>
808 <div class="columns">
809 <div class="column">
810 <div class="card metrics-card">
811 <div class="card-header">
812 <p class="card-header-title permalink-header" id="environment-timeseries">
813 🌡️ 環境データの時系列推移
814 <span class="permalink-icon" onclick="copyPermalink('environment-timeseries')">
815 <i class="fas fa-link"></i>
816 </span>
817 </p>
818 </div>
819 <div class="card-content">
820 <div class="chart-container">
821 <canvas id="environmentTimeseriesChart"></canvas>
822 </div>
823 </div>
824 </div>
825 </div>
826 </div>
827 </div>
828 """
831def generate_correlation_section() -> str:
832 """環境要因相関分析セクションのHTML生成"""
833 return """
834 <div class="section">
835 <h2 class="title is-4 permalink-header" id="correlation">
836 <span class="icon"><i class="fas fa-project-diagram"></i></span> 環境要因相関分析
837 <span class="permalink-icon" onclick="copyPermalink('correlation')">
838 <i class="fas fa-link"></i>
839 </span>
840 </h2>
842 <div class="columns">
843 <div class="column is-half">
844 <div class="card metrics-card">
845 <div class="card-header">
846 <p class="card-header-title permalink-header" id="temp-cooling-correlation">
847 🌡️❄️ 気温 vs 冷却モード
848 <span class="permalink-icon" onclick="copyPermalink('temp-cooling-correlation')">
849 <i class="fas fa-link"></i>
850 </span>
851 </p>
852 </div>
853 <div class="card-content">
854 <div class="chart-container">
855 <canvas id="tempCoolingCorrelationChart"></canvas>
856 </div>
857 </div>
858 </div>
859 </div>
860 <div class="column is-half">
861 <div class="card metrics-card">
862 <div class="card-header">
863 <p class="card-header-title permalink-header" id="humidity-duty-correlation">
864 💧⚡ 湿度 vs Duty比
865 <span class="permalink-icon" onclick="copyPermalink('humidity-duty-correlation')">
866 <i class="fas fa-link"></i>
867 </span>
868 </p>
869 </div>
870 <div class="card-content">
871 <div class="chart-container">
872 <canvas id="humidityDutyCorrelationChart"></canvas>
873 </div>
874 </div>
875 </div>
876 </div>
877 </div>
879 <div class="columns">
880 <div class="column is-half">
881 <div class="card metrics-card">
882 <div class="card-header">
883 <p class="card-header-title permalink-header" id="solar-cooling-correlation">
884 ☀️❄️ 日射量 vs 冷却モード
885 <span class="permalink-icon" onclick="copyPermalink('solar-cooling-correlation')">
886 <i class="fas fa-link"></i>
887 </span>
888 </p>
889 </div>
890 <div class="card-content">
891 <div class="chart-container">
892 <canvas id="solarCoolingCorrelationChart"></canvas>
893 </div>
894 </div>
895 </div>
896 </div>
897 <div class="column is-half">
898 <div class="card metrics-card">
899 <div class="card-header">
900 <p class="card-header-title permalink-header" id="lux-duty-correlation">
901 💡⚡ 照度 vs Duty比
902 <span class="permalink-icon" onclick="copyPermalink('lux-duty-correlation')">
903 <i class="fas fa-link"></i>
904 </span>
905 </p>
906 </div>
907 <div class="card-content">
908 <div class="chart-container">
909 <canvas id="luxDutyCorrelationChart"></canvas>
910 </div>
911 </div>
912 </div>
913 </div>
914 </div>
915 </div>
916 """
919def generate_chart_javascript() -> str:
920 """チャート生成用JavaScriptを生成"""
921 return """
922 function initializePermalinks() {
923 // ページ読み込み時にハッシュがある場合はスクロール
924 if (window.location.hash) {
925 const element = document.querySelector(window.location.hash);
926 if (element) {
927 setTimeout(() => {
928 element.scrollIntoView({ behavior: 'smooth', block: 'start' });
929 }, 500);
930 }
931 }
932 }
934 function copyPermalink(sectionId) {
935 const url = window.location.origin + window.location.pathname + '#' + sectionId;
937 if (navigator.clipboard && window.isSecureContext) {
938 navigator.clipboard.writeText(url).then(() => {
939 showCopyNotification();
940 }).catch(err => {
941 console.error('Failed to copy: ', err);
942 fallbackCopyToClipboard(url);
943 });
944 } else {
945 fallbackCopyToClipboard(url);
946 }
948 window.history.replaceState(null, null, '#' + sectionId);
949 }
951 function fallbackCopyToClipboard(text) {
952 const textArea = document.createElement("textarea");
953 textArea.value = text;
954 textArea.style.position = "fixed";
955 textArea.style.left = "-999999px";
956 textArea.style.top = "-999999px";
957 document.body.appendChild(textArea);
958 textArea.focus();
959 textArea.select();
961 try {
962 document.execCommand('copy');
963 showCopyNotification();
964 } catch (err) {
965 console.error('Fallback: Failed to copy', err);
966 prompt('URLをコピーしてください:', text);
967 }
969 document.body.removeChild(textArea);
970 }
972 function showCopyNotification() {
973 const notification = document.createElement('div');
974 notification.textContent = 'パーマリンクをコピーしました!';
975 notification.style.cssText = `
976 position: fixed;
977 top: 20px;
978 right: 20px;
979 background: #23d160;
980 color: white;
981 padding: 12px 20px;
982 border-radius: 4px;
983 z-index: 1000;
984 font-size: 14px;
985 font-weight: 500;
986 box-shadow: 0 2px 8px rgba(0,0,0,0.15);
987 transition: opacity 0.3s ease-in-out;
988 `;
990 document.body.appendChild(notification);
992 setTimeout(() => {
993 notification.style.opacity = '0';
994 setTimeout(() => {
995 if (notification.parentNode) {
996 document.body.removeChild(notification);
997 }
998 }, 300);
999 }, 3000);
1000 }
1002 function generateHourlyCharts() {
1003 // 冷却モードの時間別分布(箱ヒゲ図)
1004 const coolingModeCtx = document.getElementById('coolingModeHourlyChart');
1005 if (coolingModeCtx && chartData.boxplot_cooling_mode) {
1006 new Chart(coolingModeCtx, {
1007 type: 'boxplot',
1008 data: {
1009 labels: chartData.boxplot_cooling_mode.map(d => parseInt(d.x) + '時'),
1010 datasets: [{
1011 label: '冷却モード分布',
1012 data: chartData.boxplot_cooling_mode.map(d => d.y),
1013 backgroundColor: 'rgba(52, 152, 219, 0.6)',
1014 borderColor: 'rgb(52, 152, 219)',
1015 borderWidth: 2,
1016 outlierColor: 'rgb(239, 68, 68)',
1017 medianColor: 'rgb(255, 193, 7)'
1018 }]
1019 },
1020 options: {
1021 responsive: true,
1022 maintainAspectRatio: false,
1023 scales: {
1024 y: {
1025 beginAtZero: true,
1026 title: {
1027 display: true,
1028 text: '冷却モード'
1029 }
1030 },
1031 x: {
1032 title: {
1033 display: true,
1034 text: '時刻'
1035 },
1036 ticks: {
1037 maxTicksLimit: 12,
1038 maxRotation: 45
1039 }
1040 }
1041 },
1042 plugins: {
1043 tooltip: {
1044 callbacks: {
1045 label: function(context) {
1046 const stats = context.parsed;
1047 return [
1048 '最小値: ' + stats.min.toFixed(1),
1049 '第1四分位: ' + stats.q1.toFixed(1),
1050 '中央値: ' + stats.median.toFixed(1),
1051 '第3四分位: ' + stats.q3.toFixed(1),
1052 '最大値: ' + stats.max.toFixed(1),
1053 '外れ値数: ' + stats.outliers.length
1054 ];
1055 }
1056 }
1057 }
1058 }
1059 }
1060 });
1061 }
1063 // Duty比の時間別分布(箱ヒゲ図)
1064 const dutyRatioCtx = document.getElementById('dutyRatioHourlyChart');
1065 if (dutyRatioCtx && chartData.boxplot_duty_ratio) {
1066 new Chart(dutyRatioCtx, {
1067 type: 'boxplot',
1068 data: {
1069 labels: chartData.boxplot_duty_ratio.map(d => parseInt(d.x) + '時'),
1070 datasets: [{
1071 label: 'Duty比分布(%)',
1072 data: chartData.boxplot_duty_ratio.map(d => d.y),
1073 backgroundColor: 'rgba(46, 204, 113, 0.6)',
1074 borderColor: 'rgb(46, 204, 113)',
1075 borderWidth: 2,
1076 outlierColor: 'rgb(239, 68, 68)',
1077 medianColor: 'rgb(255, 193, 7)'
1078 }]
1079 },
1080 options: {
1081 responsive: true,
1082 maintainAspectRatio: false,
1083 scales: {
1084 y: {
1085 beginAtZero: true,
1086 title: {
1087 display: true,
1088 text: 'Duty比(%)'
1089 }
1090 },
1091 x: {
1092 title: {
1093 display: true,
1094 text: '時刻'
1095 },
1096 ticks: {
1097 maxTicksLimit: 12,
1098 maxRotation: 45
1099 }
1100 }
1101 },
1102 plugins: {
1103 tooltip: {
1104 callbacks: {
1105 label: function(context) {
1106 const stats = context.parsed;
1107 return [
1108 '最小値: ' + stats.min.toFixed(1) + '%',
1109 '第1四分位: ' + stats.q1.toFixed(1) + '%',
1110 '中央値: ' + stats.median.toFixed(1) + '%',
1111 '第3四分位: ' + stats.q3.toFixed(1) + '%',
1112 '最大値: ' + stats.max.toFixed(1) + '%',
1113 '外れ値数: ' + stats.outliers.length
1114 ];
1115 }
1116 }
1117 }
1118 }
1119 }
1120 });
1121 }
1123 // バルブ操作回数の時間別分布(箱ヒゲ図)
1124 const valveOpsCtx = document.getElementById('valveOpsHourlyChart');
1125 if (valveOpsCtx && chartData.boxplot_valve_ops) {
1126 new Chart(valveOpsCtx, {
1127 type: 'boxplot',
1128 data: {
1129 labels: chartData.boxplot_valve_ops.map(d => parseInt(d.x) + '時'),
1130 datasets: [{
1131 label: 'バルブ操作回数分布',
1132 data: chartData.boxplot_valve_ops.map(d => d.y),
1133 backgroundColor: 'rgba(241, 196, 15, 0.6)',
1134 borderColor: 'rgb(241, 196, 15)',
1135 borderWidth: 2,
1136 outlierColor: 'rgb(239, 68, 68)',
1137 medianColor: 'rgb(255, 193, 7)'
1138 }]
1139 },
1140 options: {
1141 responsive: true,
1142 maintainAspectRatio: false,
1143 scales: {
1144 y: {
1145 beginAtZero: true,
1146 title: {
1147 display: true,
1148 text: 'バルブ操作回数'
1149 }
1150 },
1151 x: {
1152 title: {
1153 display: true,
1154 text: '時刻'
1155 },
1156 ticks: {
1157 maxTicksLimit: 12,
1158 maxRotation: 45
1159 }
1160 }
1161 },
1162 plugins: {
1163 tooltip: {
1164 callbacks: {
1165 label: function(context) {
1166 const stats = context.parsed;
1167 return [
1168 '最小値: ' + stats.min.toFixed(0) + '回',
1169 '第1四分位: ' + stats.q1.toFixed(0) + '回',
1170 '中央値: ' + stats.median.toFixed(0) + '回',
1171 '第3四分位: ' + stats.q3.toFixed(0) + '回',
1172 '最大値: ' + stats.max.toFixed(0) + '回',
1173 '外れ値数: ' + stats.outliers.length
1174 ];
1175 }
1176 }
1177 }
1178 }
1179 }
1180 });
1181 }
1182 }
1184 function generateTimeseriesCharts() {
1185 // 冷却モードとDuty比の時系列
1186 const coolingDutyCtx = document.getElementById('coolingDutyTimeseriesChart');
1187 if (coolingDutyCtx && chartData.timeseries) {
1188 const timestamps = chartData.timeseries.map(d => d.timestamp);
1189 const coolingModes = chartData.timeseries.map(d => d.cooling_mode);
1190 const dutyRatios = chartData.timeseries.map(d => d.duty_ratio ? d.duty_ratio * 100 : null);
1192 new Chart(coolingDutyCtx, {
1193 type: 'line',
1194 data: {
1195 labels: timestamps,
1196 datasets: [
1197 {
1198 label: '冷却モード',
1199 data: coolingModes,
1200 borderColor: 'rgba(52, 152, 219, 1)',
1201 backgroundColor: 'rgba(52, 152, 219, 0.1)',
1202 tension: 0.1,
1203 spanGaps: true,
1204 yAxisID: 'y'
1205 },
1206 {
1207 label: 'Duty比(%)',
1208 data: dutyRatios,
1209 borderColor: 'rgba(46, 204, 113, 1)',
1210 backgroundColor: 'rgba(46, 204, 113, 0.1)',
1211 tension: 0.1,
1212 spanGaps: true,
1213 yAxisID: 'y1'
1214 }
1215 ]
1216 },
1217 options: {
1218 responsive: true,
1219 maintainAspectRatio: false,
1220 interaction: {
1221 mode: 'index',
1222 intersect: false
1223 },
1224 scales: {
1225 y: {
1226 type: 'linear',
1227 display: true,
1228 position: 'left',
1229 title: {
1230 display: true,
1231 text: '冷却モード'
1232 }
1233 },
1234 y1: {
1235 type: 'linear',
1236 display: true,
1237 position: 'right',
1238 title: {
1239 display: true,
1240 text: 'Duty比(%)'
1241 },
1242 grid: {
1243 drawOnChartArea: false,
1244 },
1245 max: 100,
1246 min: 0
1247 },
1248 x: {
1249 title: {
1250 display: true,
1251 text: '時刻'
1252 },
1253 ticks: {
1254 maxTicksLimit: Math.max(6,
1255 Math.min(20, Math.floor(timestamps.length / 10))),
1256 maxRotation: 45,
1257 minRotation: 0,
1258 autoSkip: true,
1259 autoSkipPadding: 20,
1260 callback: function(value, index, values) {
1261 const timestamp = timestamps[index];
1262 if (typeof timestamp === 'string' && timestamp.includes('/')) {
1263 return timestamp; // 既にフォーマット済み
1264 }
1265 // ISO形式の場合は変換
1266 try {
1267 const date = new Date(timestamp);
1268 const month = (date.getMonth() + 1).toString().padStart(2, '0');
1269 const day = date.getDate().toString().padStart(2, '0');
1270 const hours = date.getHours().toString().padStart(2, '0');
1271 const minutes = date.getMinutes().toString().padStart(2, '0');
1272 return `${month}/${day} ${hours}:${minutes}`;
1273 } catch {
1274 return String(timestamp).substring(0, 16);
1275 }
1276 }
1277 }
1278 }
1279 }
1280 }
1281 });
1282 }
1284 // 環境データの時系列
1285 const environmentCtx = document.getElementById('environmentTimeseriesChart');
1286 if (environmentCtx && chartData.timeseries) {
1287 const timestamps = chartData.timeseries.map(d => d.timestamp);
1288 const temperatures = chartData.timeseries.map(d => d.temperature);
1289 const solarRadiation = chartData.timeseries.map(d => d.solar_radiation);
1291 new Chart(environmentCtx, {
1292 type: 'line',
1293 data: {
1294 labels: timestamps,
1295 datasets: [
1296 {
1297 label: '気温(°C)',
1298 data: temperatures,
1299 borderColor: 'rgba(231, 76, 60, 1)',
1300 backgroundColor: 'rgba(231, 76, 60, 0.1)',
1301 tension: 0.1,
1302 spanGaps: true,
1303 yAxisID: 'y'
1304 },
1305 {
1306 label: '日射量(W/m²)',
1307 data: solarRadiation,
1308 borderColor: 'rgba(255, 193, 7, 1)',
1309 backgroundColor: 'rgba(255, 193, 7, 0.1)',
1310 tension: 0.1,
1311 spanGaps: true,
1312 yAxisID: 'y1'
1313 }
1314 ]
1315 },
1316 options: {
1317 responsive: true,
1318 maintainAspectRatio: false,
1319 interaction: {
1320 mode: 'index',
1321 intersect: false
1322 },
1323 scales: {
1324 y: {
1325 type: 'linear',
1326 display: true,
1327 position: 'left',
1328 title: {
1329 display: true,
1330 text: '気温(°C)'
1331 }
1332 },
1333 y1: {
1334 type: 'linear',
1335 display: true,
1336 position: 'right',
1337 title: {
1338 display: true,
1339 text: '日射量(W/m²)'
1340 },
1341 grid: {
1342 drawOnChartArea: false,
1343 },
1344 min: 0
1345 },
1346 x: {
1347 title: {
1348 display: true,
1349 text: '時刻'
1350 },
1351 ticks: {
1352 maxTicksLimit: Math.max(6,
1353 Math.min(20, Math.floor(timestamps.length / 10))),
1354 maxRotation: 45,
1355 minRotation: 0,
1356 autoSkip: true,
1357 autoSkipPadding: 20,
1358 callback: function(value, index, values) {
1359 const timestamp = timestamps[index];
1360 if (typeof timestamp === 'string' && timestamp.includes('/')) {
1361 return timestamp; // 既にフォーマット済み
1362 }
1363 // ISO形式の場合は変換
1364 try {
1365 const date = new Date(timestamp);
1366 const month = (date.getMonth() + 1).toString().padStart(2, '0');
1367 const day = date.getDate().toString().padStart(2, '0');
1368 const hours = date.getHours().toString().padStart(2, '0');
1369 const minutes = date.getMinutes().toString().padStart(2, '0');
1370 return `${month}/${day} ${hours}:${minutes}`;
1371 } catch {
1372 return String(timestamp).substring(0, 16);
1373 }
1374 }
1375 }
1376 }
1377 }
1378 }
1379 });
1380 }
1381 }
1383 function generateCorrelationCharts() {
1384 // 相関係数計算用のヘルパー関数
1385 function calculateCorrelation(xVals, yVals) {
1386 const validPairs = [];
1387 for (let i = 0; i < Math.min(xVals.length, yVals.length); i++) {
1388 if (xVals[i] !== null && yVals[i] !== null) {
1389 validPairs.push([xVals[i], yVals[i]]);
1390 }
1391 }
1393 if (validPairs.length < 2) return 0;
1395 const xMean = validPairs.reduce((sum, pair) => sum + pair[0], 0) / validPairs.length;
1396 const yMean = validPairs.reduce((sum, pair) => sum + pair[1], 0) / validPairs.length;
1398 let numerator = 0;
1399 let xVariance = 0;
1400 let yVariance = 0;
1402 for (const [x, y] of validPairs) {
1403 numerator += (x - xMean) * (y - yMean);
1404 xVariance += Math.pow(x - xMean, 2);
1405 yVariance += Math.pow(y - yMean, 2);
1406 }
1408 const denominator = Math.sqrt(xVariance * yVariance);
1409 return denominator === 0 ? 0 : numerator / denominator;
1410 }
1412 // 気温 vs 冷却モード
1413 const tempCoolingCtx = document.getElementById('tempCoolingCorrelationChart');
1414 if (tempCoolingCtx && chartData.correlation) {
1415 const data = [];
1416 const minLength = Math.min(
1417 chartData.correlation.temperature.length,
1418 chartData.correlation.cooling_mode.length
1419 );
1420 for (let i = 0; i < minLength; i++) {
1421 if (chartData.correlation.temperature[i] !== null &&
1422 chartData.correlation.cooling_mode[i] !== null) {
1423 data.push({
1424 x: chartData.correlation.temperature[i],
1425 y: chartData.correlation.cooling_mode[i]
1426 });
1427 }
1428 }
1430 const correlation = calculateCorrelation(
1431 chartData.correlation.temperature,
1432 chartData.correlation.cooling_mode
1433 );
1435 new Chart(tempCoolingCtx, {
1436 type: 'scatter',
1437 data: {
1438 datasets: [{
1439 label: `気温 vs 冷却モード (r=${correlation.toFixed(3)})`,
1440 data: data,
1441 backgroundColor: 'rgba(231, 76, 60, 0.6)',
1442 borderColor: 'rgba(231, 76, 60, 1)',
1443 pointRadius: 3
1444 }]
1445 },
1446 options: {
1447 responsive: true,
1448 maintainAspectRatio: false,
1449 scales: {
1450 x: {
1451 title: {
1452 display: true,
1453 text: '気温(°C)'
1454 }
1455 },
1456 y: {
1457 title: {
1458 display: true,
1459 text: '冷却モード'
1460 }
1461 }
1462 },
1463 plugins: {
1464 tooltip: {
1465 callbacks: {
1466 title: function(context) {
1467 return `データポイント: ${context.length}個`;
1468 },
1469 label: function(context) {
1470 return [
1471 `気温: ${context.parsed.x.toFixed(1)}°C`,
1472 `冷却モード: ${context.parsed.y.toFixed(1)}`,
1473 `相関係数: ${correlation.toFixed(3)}`
1474 ];
1475 },
1476 afterLabel: function() {
1477 const strength = Math.abs(correlation);
1478 if (strength >= 0.8) return '強い相関';
1479 if (strength >= 0.5) return '中程度の相関';
1480 if (strength >= 0.3) return '弱い相関';
1481 return '相関なし';
1482 }
1483 }
1484 }
1485 }
1486 }
1487 });
1488 }
1490 // 湿度 vs Duty比
1491 const humidityDutyCtx = document.getElementById('humidityDutyCorrelationChart');
1492 if (humidityDutyCtx && chartData.correlation) {
1493 const data = [];
1494 const minLength = Math.min(
1495 chartData.correlation.humidity.length,
1496 chartData.correlation.duty_ratio.length
1497 );
1498 for (let i = 0; i < minLength; i++) {
1499 if (
1500 chartData.correlation.humidity[i] !== null &&
1501 chartData.correlation.duty_ratio[i] !== null
1502 ) {
1503 data.push({
1504 x: chartData.correlation.humidity[i],
1505 y: chartData.correlation.duty_ratio[i] * 100
1506 });
1507 }
1508 }
1510 const correlation = calculateCorrelation(
1511 chartData.correlation.humidity,
1512 chartData.correlation.duty_ratio
1513 );
1515 new Chart(humidityDutyCtx, {
1516 type: 'scatter',
1517 data: {
1518 datasets: [{
1519 label: `湿度 vs Duty比 (r=${correlation.toFixed(3)})`,
1520 data: data,
1521 backgroundColor: 'rgba(155, 89, 182, 0.6)',
1522 borderColor: 'rgba(155, 89, 182, 1)',
1523 pointRadius: 3
1524 }]
1525 },
1526 options: {
1527 responsive: true,
1528 maintainAspectRatio: false,
1529 scales: {
1530 x: {
1531 title: {
1532 display: true,
1533 text: '湿度(%)'
1534 }
1535 },
1536 y: {
1537 title: {
1538 display: true,
1539 text: 'Duty比(%)'
1540 }
1541 }
1542 },
1543 plugins: {
1544 tooltip: {
1545 callbacks: {
1546 title: function(context) {
1547 return `データポイント: ${context.length}個`;
1548 },
1549 label: function(context) {
1550 return [
1551 `湿度: ${context.parsed.x.toFixed(1)}%`,
1552 `Duty比: ${context.parsed.y.toFixed(1)}%`,
1553 `相関係数: ${correlation.toFixed(3)}`
1554 ];
1555 },
1556 afterLabel: function() {
1557 const strength = Math.abs(correlation);
1558 if (strength >= 0.8) return '強い相関';
1559 if (strength >= 0.5) return '中程度の相関';
1560 if (strength >= 0.3) return '弱い相関';
1561 return '相関なし';
1562 }
1563 }
1564 }
1565 }
1566 }
1567 });
1568 }
1570 // 日射量 vs 冷却モード
1571 const solarCoolingCtx = document.getElementById('solarCoolingCorrelationChart');
1572 if (solarCoolingCtx && chartData.correlation) {
1573 const data = [];
1574 const minLength = Math.min(
1575 chartData.correlation.solar_radiation.length,
1576 chartData.correlation.cooling_mode.length
1577 );
1578 for (let i = 0; i < minLength; i++) {
1579 if (
1580 chartData.correlation.solar_radiation[i] !== null &&
1581 chartData.correlation.cooling_mode[i] !== null
1582 ) {
1583 data.push({
1584 x: chartData.correlation.solar_radiation[i],
1585 y: chartData.correlation.cooling_mode[i]
1586 });
1587 }
1588 }
1590 const solarCorrelation = calculateCorrelation(
1591 chartData.correlation.solar_radiation,
1592 chartData.correlation.cooling_mode
1593 );
1595 new Chart(solarCoolingCtx, {
1596 type: 'scatter',
1597 data: {
1598 datasets: [{
1599 label: `日射量 vs 冷却モード (r=${solarCorrelation.toFixed(3)})`,
1600 data: data,
1601 backgroundColor: 'rgba(243, 156, 18, 0.6)',
1602 borderColor: 'rgba(243, 156, 18, 1)',
1603 pointRadius: 3
1604 }]
1605 },
1606 options: {
1607 responsive: true,
1608 maintainAspectRatio: false,
1609 scales: {
1610 x: {
1611 title: {
1612 display: true,
1613 text: '日射量(W/m²)'
1614 }
1615 },
1616 y: {
1617 title: {
1618 display: true,
1619 text: '冷却モード'
1620 }
1621 }
1622 },
1623 plugins: {
1624 tooltip: {
1625 callbacks: {
1626 title: function(context) {
1627 return `データポイント: ${context.length}個`;
1628 },
1629 label: function(context) {
1630 return [
1631 `日射量: ${context.parsed.x.toFixed(1)} W/m²`,
1632 `冷却モード: ${context.parsed.y.toFixed(1)}`,
1633 `相関係数: ${solarCorrelation.toFixed(3)}`
1634 ];
1635 },
1636 afterLabel: function() {
1637 const strength = Math.abs(solarCorrelation);
1638 if (strength >= 0.8) return '強い相関';
1639 if (strength >= 0.5) return '中程度の相関';
1640 if (strength >= 0.3) return '弱い相関';
1641 return '相関なし';
1642 }
1643 }
1644 }
1645 }
1646 }
1647 });
1648 }
1650 // 照度 vs Duty比
1651 const luxDutyCtx = document.getElementById('luxDutyCorrelationChart');
1652 if (luxDutyCtx && chartData.correlation) {
1653 const data = [];
1654 const minLength = Math.min(
1655 chartData.correlation.lux.length,
1656 chartData.correlation.duty_ratio.length
1657 );
1658 for (let i = 0; i < minLength; i++) {
1659 if (
1660 chartData.correlation.lux[i] !== null &&
1661 chartData.correlation.duty_ratio[i] !== null
1662 ) {
1663 data.push({
1664 x: chartData.correlation.lux[i],
1665 y: chartData.correlation.duty_ratio[i] * 100
1666 });
1667 }
1668 }
1670 const luxCorrelation = calculateCorrelation(
1671 chartData.correlation.lux,
1672 chartData.correlation.duty_ratio
1673 );
1675 new Chart(luxDutyCtx, {
1676 type: 'scatter',
1677 data: {
1678 datasets: [{
1679 label: `照度 vs Duty比 (r=${luxCorrelation.toFixed(3)})`,
1680 data: data,
1681 backgroundColor: 'rgba(52, 152, 219, 0.6)',
1682 borderColor: 'rgba(52, 152, 219, 1)',
1683 pointRadius: 3
1684 }]
1685 },
1686 options: {
1687 responsive: true,
1688 maintainAspectRatio: false,
1689 scales: {
1690 x: {
1691 title: {
1692 display: true,
1693 text: '照度(lux)'
1694 }
1695 },
1696 y: {
1697 title: {
1698 display: true,
1699 text: 'Duty比(%)'
1700 }
1701 }
1702 },
1703 plugins: {
1704 tooltip: {
1705 callbacks: {
1706 title: function(context) {
1707 return `データポイント: ${context.length}個`;
1708 },
1709 label: function(context) {
1710 return [
1711 `照度: ${context.parsed.x.toFixed(1)} lux`,
1712 `Duty比: ${context.parsed.y.toFixed(1)}%`,
1713 `相関係数: ${luxCorrelation.toFixed(3)}`
1714 ];
1715 },
1716 afterLabel: function() {
1717 const strength = Math.abs(luxCorrelation);
1718 if (strength >= 0.8) return '強い相関';
1719 if (strength >= 0.5) return '中程度の相関';
1720 if (strength >= 0.3) return '弱い相関';
1721 return '相関なし';
1722 }
1723 }
1724 }
1725 }
1726 }
1727 });
1728 }
1729 }
1730 """