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

1""" 

2室外機冷却システムメトリクス表示ページ 

3 

4冷却モード、Duty比、環境要因の統計情報とグラフを表示するWebページを提供します。 

5""" 

6 

7from __future__ import annotations 

8 

9import datetime 

10import io 

11import json 

12import logging 

13import zoneinfo 

14 

15import flask 

16import my_lib.webapp.config 

17from PIL import Image, ImageDraw 

18 

19from unit_cooler.metrics.collector import get_metrics_collector 

20 

21blueprint = flask.Blueprint("metrics", __name__, url_prefix=my_lib.webapp.config.URL_PREFIX) 

22 

23 

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") 

31 

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 ) 

40 

41 from pathlib import Path 

42 

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 ) 

52 

53 # メトリクス収集器を取得 

54 collector = get_metrics_collector(metrics_data_path) 

55 

56 # 全期間のデータを取得(制限なし) 

57 end_time = datetime.datetime.now(zoneinfo.ZoneInfo("Asia/Tokyo")) 

58 start_time = None # 無制限 

59 

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) 

63 

64 # 統計データを生成 

65 stats = generate_statistics(minute_data, hourly_data, error_data) 

66 

67 # データ期間情報を取得 

68 period_info = get_data_period_info(minute_data, hourly_data) 

69 

70 # HTMLを生成 

71 html_content = generate_metrics_html(stats, minute_data, hourly_data, period_info) 

72 

73 return flask.Response(html_content, mimetype="text/html") 

74 

75 except Exception as e: 

76 logging.exception("メトリクス表示の生成エラー") 

77 return flask.Response(f"エラー: {e!s}", mimetype="text/plain", status=500) 

78 

79 

80@blueprint.route("/favicon.ico", methods=["GET"]) 

81def favicon(): 

82 """動的生成された室外機冷却システムメトリクス用favicon.icoを返す""" 

83 try: 

84 # 室外機冷却システムメトリクスアイコンを生成 

85 img = generate_cooler_metrics_icon() 

86 

87 # ICO形式で出力 

88 output = io.BytesIO() 

89 img.save(output, format="ICO", sizes=[(32, 32)]) 

90 output.seek(0) 

91 

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) 

103 

104 

105def generate_cooler_metrics_icon(): 

106 """室外機冷却システムメトリクス用のアイコンを動的生成(アンチエイリアス対応)""" 

107 # アンチエイリアスのため4倍サイズで描画してから縮小 

108 scale = 4 

109 size = 32 

110 large_size = size * scale 

111 

112 # 大きなサイズで描画 

113 img = Image.new("RGBA", (large_size, large_size), (0, 0, 0, 0)) 

114 draw = ImageDraw.Draw(img) 

115 

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 ) 

124 

125 # 冷却アイコン(雪の結晶風) 

126 center_x, center_y = large_size // 2, large_size // 2 

127 

128 # 雪の結晶の線 

129 for angle in [0, 60, 120]: 

130 import math 

131 

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) 

138 

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) 

148 

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 ) 

154 

155 # 32x32に縮小してアンチエイリアス効果を得る 

156 return img.resize((size, size), Image.LANCZOS) 

157 

158 

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": "データなし"} 

164 

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"]) 

182 

183 if not timestamps: 

184 return { 

185 "start_date": None, 

186 "end_date": None, 

187 "total_days": 0, 

188 "period_text": "タイムスタンプ情報なし", 

189 } 

190 

191 start_date = min(timestamps) 

192 end_date = max(timestamps) 

193 total_days = (end_date - start_date).days + 1 

194 

195 # 期間テキストを生成 

196 start_str = start_date.strftime("%Y年%m月%d日") 

197 period_text = f"過去{total_days}日間({start_str}〜)の冷却システム統計" 

198 

199 return { 

200 "start_date": start_date, 

201 "end_date": end_date, 

202 "total_days": total_days, 

203 "period_text": period_text, 

204 } 

205 

206 

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 } 

220 

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] 

226 

227 valve_operations_total = sum(d.get("valve_operations", 0) for d in hourly_data) 

228 

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") 

242 

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 } 

253 

254 

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 

259 

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 

266 

267 x_vals, y_vals = zip(*valid_pairs, strict=False) 

268 n = len(x_vals) 

269 

270 # 平均を計算 

271 x_mean = sum(x_vals) / n 

272 y_mean = sum(y_vals) / n 

273 

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) 

278 

279 denominator = (x_variance * y_variance) ** 0.5 

280 

281 if denominator == 0: 

282 return 0.0 

283 

284 return numerator / denominator 

285 

286 

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": []} 

291 

292 values_sorted = sorted(values) 

293 n = len(values_sorted) 

294 

295 # 四分位数を計算 

296 q1_idx = n // 4 

297 median_idx = n // 2 

298 q3_idx = 3 * n // 4 

299 

300 q1 = values_sorted[q1_idx] 

301 median = values_sorted[median_idx] 

302 q3 = values_sorted[q3_idx] 

303 

304 # IQRと外れ値を計算 

305 iqr = q3 - q1 

306 lower_bound = q1 - 1.5 * iqr 

307 upper_bound = q3 + 1.5 * iqr 

308 

309 # 外れ値を特定 

310 outliers = [v for v in values_sorted if v < lower_bound or v > upper_bound] 

311 

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] 

316 

317 return {"min": min_val, "q1": q1, "median": median, "q3": q3, "max": max_val, "outliers": outliers} 

318 

319 

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 

334 

335 

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)] 

341 

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"]) 

351 

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"]) 

358 

359 return hourly_cooling_mode, hourly_duty_ratio, hourly_valve_ops 

360 

361 

362def _prepare_timeseries_data(minute_data: list[dict]) -> list[dict]: 

363 """時系列データを準備(過去100日分)""" 

364 timeseries_data = [] 

365 

366 # 過去100日分のデータを取得(144000分) 

367 recent_data = minute_data[-144000:] if len(minute_data) > 144000 else minute_data 

368 

369 # 時系列表示のため、データを古い順(昇順)に並び替え 

370 recent_data = list(reversed(recent_data)) 

371 

372 # データポイント数が多い場合は平均化して処理 

373 target_points = 1000 # 目標ポイント数 

374 if len(recent_data) > target_points: 

375 # データを等間隔に分割して平均化 

376 chunk_size = len(recent_data) // target_points 

377 averaged_data = [] 

378 

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 

383 

384 # チャンクの最初のタイムスタンプを使用 

385 base_data = chunk[0] 

386 

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) 

414 

415 recent_data = averaged_data 

416 

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") 

432 

433 # 簡潔な形式にフォーマット 

434 if hasattr(timestamp, "strftime"): 

435 formatted_timestamp = timestamp.strftime("%m/%d %H:%M") 

436 else: 

437 formatted_timestamp = str(timestamp) 

438 

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 

452 

453 

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 } 

467 

468 

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 = [] 

476 

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 ) 

481 

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)}) 

485 

486 boxplot_valve_ops.append( 

487 {"x": f"{hour:02d}:00", "y": calculate_boxplot_stats(hourly_valve_ops[hour])} 

488 ) 

489 

490 return boxplot_cooling_mode, boxplot_duty_ratio, boxplot_valve_ops 

491 

492 

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 ) 

502 

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 } 

513 

514 

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) 

522 

523 # URL_PREFIXを取得してfaviconパスを構築 

524 favicon_path = f"{my_lib.webapp.config.URL_PREFIX}/favicon.ico" 

525 

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> 

586 

587 <!-- 基本統計 --> 

588 {generate_basic_stats_section(stats)} 

589 

590 <!-- 時間別分布分析 --> 

591 {generate_hourly_analysis_section()} 

592 

593 <!-- 時系列データ分析 --> 

594 {generate_timeseries_section()} 

595 

596 <!-- 環境要因相関分析 --> 

597 {generate_correlation_section()} 

598 </div> 

599 </section> 

600 </div> 

601 

602 <script> 

603 const chartData = {chart_data_json}; 

604 

605 // チャート生成 

606 generateHourlyCharts(); 

607 generateTimeseriesCharts(); 

608 generateCorrelationCharts(); 

609 

610 // パーマリンク機能を初期化 

611 initializePermalinks(); 

612 

613 {generate_chart_javascript()} 

614 </script> 

615</html> 

616 """ 

617 

618 

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}" 

622 

623 

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}" 

627 

628 

629def _format_valve_operations(stats: dict) -> str: 

630 """バルブ操作回数をフォーマット""" 

631 return f"{stats['valve_operations_total']:,}" 

632 

633 

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> 

645 

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 """ 

701 

702 

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> 

713 

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> 

733 

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> 

753 

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 """ 

775 

776 

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> 

787 

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> 

807 

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 """ 

829 

830 

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> 

841 

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> 

878 

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 """ 

917 

918 

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 } 

933 

934 function copyPermalink(sectionId) { 

935 const url = window.location.origin + window.location.pathname + '#' + sectionId; 

936 

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 } 

947 

948 window.history.replaceState(null, null, '#' + sectionId); 

949 } 

950 

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(); 

960 

961 try { 

962 document.execCommand('copy'); 

963 showCopyNotification(); 

964 } catch (err) { 

965 console.error('Fallback: Failed to copy', err); 

966 prompt('URLをコピーしてください:', text); 

967 } 

968 

969 document.body.removeChild(textArea); 

970 } 

971 

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 `; 

989 

990 document.body.appendChild(notification); 

991 

992 setTimeout(() => { 

993 notification.style.opacity = '0'; 

994 setTimeout(() => { 

995 if (notification.parentNode) { 

996 document.body.removeChild(notification); 

997 } 

998 }, 300); 

999 }, 3000); 

1000 } 

1001 

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 } 

1062 

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 } 

1122 

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 } 

1183 

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); 

1191 

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 } 

1283 

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); 

1290 

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 } 

1382 

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 } 

1392 

1393 if (validPairs.length < 2) return 0; 

1394 

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; 

1397 

1398 let numerator = 0; 

1399 let xVariance = 0; 

1400 let yVariance = 0; 

1401 

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 } 

1407 

1408 const denominator = Math.sqrt(xVariance * yVariance); 

1409 return denominator === 0 ? 0 : numerator / denominator; 

1410 } 

1411 

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 } 

1429 

1430 const correlation = calculateCorrelation( 

1431 chartData.correlation.temperature, 

1432 chartData.correlation.cooling_mode 

1433 ); 

1434 

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 } 

1489 

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 } 

1509 

1510 const correlation = calculateCorrelation( 

1511 chartData.correlation.humidity, 

1512 chartData.correlation.duty_ratio 

1513 ); 

1514 

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 } 

1569 

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 } 

1589 

1590 const solarCorrelation = calculateCorrelation( 

1591 chartData.correlation.solar_radiation, 

1592 chartData.correlation.cooling_mode 

1593 ); 

1594 

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 } 

1649 

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 } 

1669 

1670 const luxCorrelation = calculateCorrelation( 

1671 chartData.correlation.lux, 

1672 chartData.correlation.duty_ratio 

1673 ); 

1674 

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 """