Coverage for flask/src/rasp_shutter/metrics/webapi/page.py: 12%
185 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-08-23 19:38 +0900
« prev ^ index » next coverage.py v7.9.1, created at 2025-08-23 19:38 +0900
1#!/usr/bin/env python3
2"""
3シャッターメトリクス表示ページ
5シャッター操作の統計情報とグラフを表示するWebページを提供します。
6"""
8from __future__ import annotations
10import datetime
11import io
12import json
13import logging
15import my_lib.webapp.config
16import rasp_shutter.metrics.collector
17from PIL import Image, ImageDraw
19import flask
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("metrics", {}).get("data")
32 # データベースファイルの存在確認
33 if not metrics_data_path:
34 return flask.Response(
35 "<html><body><h1>メトリクス設定が見つかりません</h1>"
36 "<p>config.yamlで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 = rasp_shutter.metrics.collector.get_collector(metrics_data_path)
56 # 全期間のデータを取得
57 operation_metrics = collector.get_all_operation_metrics()
58 failure_metrics = collector.get_all_failure_metrics()
60 # 統計データを生成
61 stats = generate_statistics(operation_metrics, failure_metrics)
63 # データ期間を計算
64 data_period = calculate_data_period(operation_metrics)
66 # HTMLを生成
67 html_content = generate_metrics_html(stats, operation_metrics, data_period)
69 return flask.Response(html_content, mimetype="text/html")
71 except Exception as e:
72 logging.exception("メトリクス表示の生成エラー")
73 return flask.Response(f"エラー: {e!s}", mimetype="text/plain", status=500)
76@blueprint.route("/favicon.ico", methods=["GET"])
77def favicon():
78 """動的生成されたシャッターメトリクス用favicon.icoを返す"""
79 try:
80 # シャッターメトリクスアイコンを生成
81 img = generate_shutter_metrics_icon()
83 # ICO形式で出力
84 output = io.BytesIO()
85 img.save(output, format="ICO", sizes=[(32, 32)])
86 output.seek(0)
88 return flask.Response(
89 output.getvalue(),
90 mimetype="image/x-icon",
91 headers={
92 "Cache-Control": "public, max-age=3600", # 1時間キャッシュ
93 "Content-Type": "image/x-icon",
94 },
95 )
96 except Exception:
97 logging.exception("favicon生成エラー")
98 return flask.Response("", status=500)
101def generate_shutter_metrics_icon():
102 """シャッターメトリクス用のアイコンを動的生成(アンチエイリアス対応)"""
103 # アンチエイリアスのため4倍サイズで描画してから縮小
104 scale = 4
105 size = 32
106 large_size = size * scale
108 # 大きなサイズで描画
109 img = Image.new("RGBA", (large_size, large_size), (0, 0, 0, 0))
110 draw = ImageDraw.Draw(img)
112 # 背景円(メトリクスらしい青色)
113 margin = 2 * scale
114 draw.ellipse(
115 [margin, margin, large_size - margin, large_size - margin],
116 fill=(52, 152, 219, 255),
117 outline=(41, 128, 185, 255),
118 width=2 * scale,
119 )
121 # グラフっぽい線を描画(座標を4倍に拡大)
122 points = [
123 (8 * scale, 20 * scale),
124 (12 * scale, 16 * scale),
125 (16 * scale, 12 * scale),
126 (20 * scale, 14 * scale),
127 (24 * scale, 10 * scale),
128 ]
130 # 折れ線グラフ
131 for i in range(len(points) - 1):
132 draw.line([points[i], points[i + 1]], fill=(255, 255, 255, 255), width=2 * scale)
134 # データポイント
135 point_size = 1 * scale
136 for point in points:
137 draw.ellipse(
138 [point[0] - point_size, point[1] - point_size, point[0] + point_size, point[1] + point_size],
139 fill=(255, 255, 255, 255),
140 )
142 # 32x32に縮小してアンチエイリアス効果を得る
143 return img.resize((size, size), Image.LANCZOS)
146def calculate_data_period(operation_metrics: list[dict]) -> dict:
147 """データ期間を計算"""
148 if not operation_metrics:
149 return {"total_days": 0, "start_date": None, "end_date": None, "display_text": "データなし"}
151 # 日付のみを抽出
152 dates = [op.get("date") for op in operation_metrics if op.get("date")]
154 if not dates:
155 return {"total_days": 0, "start_date": None, "end_date": None, "display_text": "データなし"}
157 # 最古と最新の日付を取得
158 start_date = min(dates)
159 end_date = max(dates)
161 # 日数を計算
162 start_dt = datetime.datetime.fromisoformat(start_date)
163 end_dt = datetime.datetime.fromisoformat(end_date)
164 total_days = (end_dt - start_dt).days + 1
166 # 表示テキストを生成
167 if total_days == 1:
168 display_text = f"過去1日間({start_date.replace('-', '年', 1).replace('-', '月', 1)}日)"
169 else:
170 start_display = start_date.replace("-", "年", 1).replace("-", "月", 1) + "日"
171 display_text = f"過去{total_days}日間({start_display}〜)"
173 return {
174 "total_days": total_days,
175 "start_date": start_date,
176 "end_date": end_date,
177 "display_text": display_text,
178 }
181def _extract_time_data(day_data: dict, key: str) -> float | None:
182 """時刻データを抽出して時間形式に変換"""
183 if not day_data.get(key):
184 return None
185 try:
186 dt = datetime.datetime.fromisoformat(day_data[key].replace("Z", "+00:00"))
187 return dt.hour + dt.minute / 60.0
188 except (ValueError, TypeError):
189 return None
192def _collect_sensor_data_by_type(operation_metrics: list[dict], operation_type: str) -> dict:
193 """操作タイプ別にセンサーデータを収集"""
194 sensor_data = {
195 "open_lux": [],
196 "close_lux": [],
197 "open_solar_rad": [],
198 "close_solar_rad": [],
199 "open_altitude": [],
200 "close_altitude": [],
201 }
203 for op_data in operation_metrics:
204 if op_data.get("operation_type") == operation_type:
205 action = op_data.get("action")
206 if action in ["open", "close"]:
207 for sensor_type in ["lux", "solar_rad", "altitude"]:
208 if op_data.get(sensor_type) is not None:
209 sensor_data[f"{action}_{sensor_type}"].append(op_data[sensor_type])
211 return sensor_data
214def generate_statistics(operation_metrics: list[dict], failure_metrics: list[dict]) -> dict:
215 """メトリクスデータから統計情報を生成"""
216 if not operation_metrics:
217 return {
218 "total_days": 0,
219 "open_times": [],
220 "close_times": [],
221 "auto_sensor_data": {
222 "open_lux": [],
223 "close_lux": [],
224 "open_solar_rad": [],
225 "close_solar_rad": [],
226 "open_altitude": [],
227 "close_altitude": [],
228 },
229 "manual_sensor_data": {
230 "open_lux": [],
231 "close_lux": [],
232 "open_solar_rad": [],
233 "close_solar_rad": [],
234 "open_altitude": [],
235 "close_altitude": [],
236 },
237 "manual_open_total": 0,
238 "manual_close_total": 0,
239 "auto_open_total": 0,
240 "auto_close_total": 0,
241 "failure_total": len(failure_metrics),
242 }
244 # 日付ごとの最後の操作時刻を取得(時刻分析用)
245 daily_last_operations = {}
246 for op_data in operation_metrics:
247 date = op_data.get("date")
248 action = op_data.get("action")
249 timestamp = op_data.get("timestamp")
251 if date and action and timestamp:
252 key = f"{date}_{action}"
253 # より新しい時刻で上書き(最後の操作時刻を保持)
254 daily_last_operations[key] = timestamp
256 # 時刻データを収集(最後の操作時刻のみ)
257 open_times = []
258 close_times = []
260 for key, timestamp in daily_last_operations.items():
261 if (
262 key.endswith("_open")
263 and (t := _extract_time_data({"timestamp": timestamp}, "timestamp")) is not None
264 ):
265 open_times.append(t)
266 elif (
267 key.endswith("_close")
268 and (t := _extract_time_data({"timestamp": timestamp}, "timestamp")) is not None
269 ):
270 close_times.append(t)
272 # センサーデータを操作タイプ別に収集
273 auto_sensor_data = _collect_sensor_data_by_type(operation_metrics, "auto")
274 auto_sensor_data.update(_collect_sensor_data_by_type(operation_metrics, "schedule"))
275 manual_sensor_data = _collect_sensor_data_by_type(operation_metrics, "manual")
277 # カウント系データを集計
278 manual_open_total = sum(
279 1 for op in operation_metrics if op.get("operation_type") == "manual" and op.get("action") == "open"
280 )
281 manual_close_total = sum(
282 1 for op in operation_metrics if op.get("operation_type") == "manual" and op.get("action") == "close"
283 )
284 auto_open_total = sum(
285 1
286 for op in operation_metrics
287 if op.get("operation_type") in ["auto", "schedule"] and op.get("action") == "open"
288 )
289 auto_close_total = sum(
290 1
291 for op in operation_metrics
292 if op.get("operation_type") in ["auto", "schedule"] and op.get("action") == "close"
293 )
295 # 日数を計算
296 unique_dates = {op.get("date") for op in operation_metrics if op.get("date")}
298 return {
299 "total_days": len(unique_dates),
300 "open_times": open_times,
301 "close_times": close_times,
302 "auto_sensor_data": auto_sensor_data,
303 "manual_sensor_data": manual_sensor_data,
304 "manual_open_total": manual_open_total,
305 "manual_close_total": manual_close_total,
306 "auto_open_total": auto_open_total,
307 "auto_close_total": auto_close_total,
308 "failure_total": len(failure_metrics),
309 }
312def generate_metrics_html(stats: dict, operation_metrics: list[dict], data_period: dict) -> str:
313 """Bulma CSSを使用したメトリクスHTMLを生成"""
314 # JavaScript用データを準備
315 chart_data = {
316 "open_times": stats["open_times"],
317 "close_times": stats["close_times"],
318 "auto_sensor_data": stats["auto_sensor_data"],
319 "manual_sensor_data": stats["manual_sensor_data"],
320 "time_series": prepare_time_series_data(operation_metrics),
321 }
323 chart_data_json = json.dumps(chart_data)
325 # URL_PREFIXを取得してfaviconパスを構築
326 favicon_path = f"{my_lib.webapp.config.URL_PREFIX}/favicon.ico"
328 return f"""
329<!DOCTYPE html>
330<html>
331<head>
332 <meta charset="utf-8">
333 <meta name="viewport" content="width=device-width, initial-scale=1">
334 <title>シャッター メトリクス ダッシュボード</title>
335 <link rel="icon" type="image/x-icon" href="{favicon_path}">
336 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css">
337 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
338 <script src="https://cdn.jsdelivr.net/npm/@sgratzl/chartjs-chart-boxplot"></script>
339 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
340 <style>
341 .metrics-card {{ margin-bottom: 1rem; }}
342 @media (max-width: 768px) {{
343 .metrics-card {{ margin-bottom: 0.75rem; }}
344 }}
345 .stat-number {{ font-size: 2rem; font-weight: bold; }}
346 .chart-container {{ position: relative; height: 350px; margin: 0.5rem 0; }}
347 @media (max-width: 768px) {{
348 .chart-container {{ height: 300px; margin: 0.25rem 0; }}
349 .container.is-fluid {{ padding: 0.25rem !important; }}
350 .section {{ padding: 0.5rem 0.25rem !important; }}
351 .card {{ margin-bottom: 1rem !important; }}
352 .columns {{ margin: 0 !important; }}
353 .column {{ padding: 0.25rem !important; }}
354 }}
355 .japanese-font {{
356 font-family: "Hiragino Sans", "Hiragino Kaku Gothic ProN",
357 "Noto Sans CJK JP", "Yu Gothic", sans-serif;
358 }}
359 .permalink-header {{
360 position: relative;
361 display: inline-block;
362 }}
363 .permalink-icon {{
364 opacity: 0;
365 transition: opacity 0.2s ease-in-out;
366 cursor: pointer;
367 color: #4a90e2;
368 margin-left: 0.5rem;
369 font-size: 0.8em;
370 }}
371 .permalink-header:hover .permalink-icon {{
372 opacity: 1;
373 }}
374 .permalink-icon:hover {{
375 color: #357abd;
376 }}
377 </style>
378</head>
379<body class="japanese-font">
380 <div class="container is-fluid" style="padding: 0.5rem;">
381 <section class="section" style="padding: 1rem 0.5rem;">
382 <div class="container" style="max-width: 100%; padding: 0;">
383 <h1 class="title is-2 has-text-centered">
384 <span class="icon is-large"><i class="fas fa-chart-line"></i></span>
385 シャッター メトリクス ダッシュボード
386 </h1>
387 <p class="subtitle has-text-centered">{data_period["display_text"]}のシャッター操作統計</p>
389 <!-- 基本統計 -->
390 {generate_basic_stats_section(stats)}
392 <!-- 時刻分析 -->
393 {generate_time_analysis_section()}
395 <!-- 時系列データ分析 -->
396 {generate_time_series_section()}
398 <!-- センサーデータ分析 -->
399 {generate_sensor_analysis_section()}
400 </div>
401 </section>
402 </div>
404 <script>
405 const chartData = {chart_data_json};
407 // チャート生成
408 generateTimeCharts();
409 generateTimeSeriesCharts();
410 generateAutoSensorCharts();
411 generateManualSensorCharts();
413 // パーマリンク機能を初期化
414 initializePermalinks();
416 {generate_chart_javascript()}
417 </script>
418</html>
419 """
422def _extract_daily_last_operations(operation_metrics: list[dict]) -> dict:
423 """日付ごとの最後の操作時刻とセンサーデータを取得"""
424 daily_last_operations = {}
426 for op_data in operation_metrics:
427 date = op_data.get("date")
428 action = op_data.get("action")
429 timestamp = op_data.get("timestamp")
431 if date and action and timestamp:
432 key = f"{date}_{action}"
433 # より新しい時刻で上書き
434 if key not in daily_last_operations or timestamp > daily_last_operations[key]["timestamp"]:
435 daily_last_operations[key] = {
436 "timestamp": timestamp,
437 "lux": op_data.get("lux"),
438 "solar_rad": op_data.get("solar_rad"),
439 "altitude": op_data.get("altitude"),
440 }
442 return daily_last_operations
445def _extract_daily_data(date: str, action: str, daily_last_operations: dict) -> tuple[float | None, ...]:
446 """指定した日付と操作の時刻とセンサーデータを抽出"""
447 key = f"{date}_{action}"
448 time_val = None
449 lux_val = None
450 solar_rad_val = None
451 altitude_val = None
453 if key in daily_last_operations:
454 try:
455 dt = datetime.datetime.fromisoformat(
456 daily_last_operations[key]["timestamp"].replace("Z", "+00:00")
457 )
458 time_val = dt.hour + dt.minute / 60.0
459 lux_val = daily_last_operations[key]["lux"]
460 solar_rad_val = daily_last_operations[key]["solar_rad"]
461 altitude_val = daily_last_operations[key]["altitude"]
462 except (ValueError, TypeError):
463 pass
465 return time_val, lux_val, solar_rad_val, altitude_val
468def prepare_time_series_data(operation_metrics: list[dict]) -> dict:
469 """時系列データを準備"""
470 daily_last_operations = _extract_daily_last_operations(operation_metrics)
472 # 日付リストを生成
473 unique_dates = sorted({op.get("date") for op in operation_metrics if op.get("date")})
475 dates = []
476 open_times = []
477 close_times = []
478 open_lux = []
479 close_lux = []
480 open_solar_rad = []
481 close_solar_rad = []
482 open_altitude = []
483 close_altitude = []
485 for date in unique_dates:
486 dates.append(date)
488 # その日の最後の開操作時刻とセンサーデータ
489 open_time, open_lux_val, open_solar_rad_val, open_altitude_val = _extract_daily_data(
490 date, "open", daily_last_operations
491 )
493 # その日の最後の閉操作時刻とセンサーデータ
494 close_time, close_lux_val, close_solar_rad_val, close_altitude_val = _extract_daily_data(
495 date, "close", daily_last_operations
496 )
498 open_times.append(open_time)
499 close_times.append(close_time)
500 open_lux.append(open_lux_val)
501 close_lux.append(close_lux_val)
502 open_solar_rad.append(open_solar_rad_val)
503 close_solar_rad.append(close_solar_rad_val)
504 open_altitude.append(open_altitude_val)
505 close_altitude.append(close_altitude_val)
507 return {
508 "dates": dates,
509 "open_times": open_times,
510 "close_times": close_times,
511 "open_lux": open_lux,
512 "close_lux": close_lux,
513 "open_solar_rad": open_solar_rad,
514 "close_solar_rad": close_solar_rad,
515 "open_altitude": open_altitude,
516 "close_altitude": close_altitude,
517 }
520def generate_basic_stats_section(stats: dict) -> str:
521 """基本統計セクションのHTML生成"""
522 return f"""
523 <div class="section">
524 <h2 class="title is-4 permalink-header" id="basic-stats">
525 <span class="icon"><i class="fas fa-chart-bar"></i></span>
526 基本統計
527 <span class="permalink-icon" onclick="copyPermalink('basic-stats')">
528 <i class="fas fa-link"></i>
529 </span>
530 </h2>
532 <div class="columns">
533 <div class="column">
534 <div class="card metrics-card">
535 <div class="card-header">
536 <p class="card-header-title">操作回数</p>
537 </div>
538 <div class="card-content">
539 <div class="columns is-multiline">
540 <div class="column is-2">
541 <div class="has-text-centered">
542 <p class="heading">👆 手動開操作 ☀️</p>
543 <p class="stat-number has-text-success">{stats["manual_open_total"]:,}</p>
544 </div>
545 </div>
546 <div class="column is-2">
547 <div class="has-text-centered">
548 <p class="heading">👆 手動閉操作 🌙</p>
549 <p class="stat-number has-text-info">{stats["manual_close_total"]:,}</p>
550 </div>
551 </div>
552 <div class="column is-2">
553 <div class="has-text-centered">
554 <p class="heading">🤖 自動開操作 ☀️</p>
555 <p class="stat-number has-text-success">{stats["auto_open_total"]:,}</p>
556 </div>
557 </div>
558 <div class="column is-2">
559 <div class="has-text-centered">
560 <p class="heading">🤖 自動閉操作 🌙</p>
561 <p class="stat-number has-text-info">{stats["auto_close_total"]:,}</p>
562 </div>
563 </div>
564 <div class="column is-2">
565 <div class="has-text-centered">
566 <p class="heading">制御失敗</p>
567 <p class="stat-number has-text-danger">{stats["failure_total"]:,}</p>
568 </div>
569 </div>
570 <div class="column is-2">
571 <div class="has-text-centered">
572 <p class="heading">データ収集日数</p>
573 <p class="stat-number has-text-primary">{stats["total_days"]:,}</p>
574 </div>
575 </div>
576 </div>
577 </div>
578 </div>
579 </div>
580 </div>
581 </div>
582 """
585def generate_time_analysis_section() -> str:
586 """時刻分析セクションのHTML生成"""
587 return """
588 <div class="section">
589 <h2 class="title is-4 permalink-header" id="time-analysis">
590 <span class="icon"><i class="fas fa-clock"></i></span> 時刻分析
591 <span class="permalink-icon" onclick="copyPermalink('time-analysis')">
592 <i class="fas fa-link"></i>
593 </span>
594 </h2>
596 <div class="columns">
597 <div class="column is-half">
598 <div class="card metrics-card">
599 <div class="card-header">
600 <p class="card-header-title permalink-header" id="open-time-histogram">
601 ☀️ 開操作時刻の頻度分布
602 <span class="permalink-icon" onclick="copyPermalink('open-time-histogram')">
603 <i class="fas fa-link"></i>
604 </span>
605 </p>
606 </div>
607 <div class="card-content">
608 <div class="chart-container">
609 <canvas id="openTimeHistogramChart"></canvas>
610 </div>
611 </div>
612 </div>
613 </div>
614 <div class="column is-half">
615 <div class="card metrics-card">
616 <div class="card-header">
617 <p class="card-header-title permalink-header" id="close-time-histogram">
618 🌙 閉操作時刻の頻度分布
619 <span class="permalink-icon" onclick="copyPermalink('close-time-histogram')">
620 <i class="fas fa-link"></i>
621 </span>
622 </p>
623 </div>
624 <div class="card-content">
625 <div class="chart-container">
626 <canvas id="closeTimeHistogramChart"></canvas>
627 </div>
628 </div>
629 </div>
630 </div>
631 </div>
632 </div>
633 """
636def generate_time_series_section() -> str:
637 """時系列データ分析セクションのHTML生成"""
638 return """
639 <div class="section">
640 <h2 class="title is-4 permalink-header" id="time-series">
641 <span class="icon"><i class="fas fa-chart-line"></i></span> 時系列データ分析
642 <span class="permalink-icon" onclick="copyPermalink('time-series')">
643 <i class="fas fa-link"></i>
644 </span>
645 </h2>
647 <div class="columns">
648 <div class="column">
649 <div class="card metrics-card">
650 <div class="card-header">
651 <p class="card-header-title permalink-header" id="time-series-chart">
652 🕐 操作時刻の時系列遷移
653 <span class="permalink-icon" onclick="copyPermalink('time-series-chart')">
654 <i class="fas fa-link"></i>
655 </span>
656 </p>
657 </div>
658 <div class="card-content">
659 <div class="chart-container">
660 <canvas id="timeSeriesChart"></canvas>
661 </div>
662 </div>
663 </div>
664 </div>
665 </div>
667 <div class="columns">
668 <div class="column">
669 <div class="card metrics-card">
670 <div class="card-header">
671 <p class="card-header-title permalink-header" id="lux-time-series">
672 💡 照度データの時系列遷移
673 <span class="permalink-icon" onclick="copyPermalink('lux-time-series')">
674 <i class="fas fa-link"></i>
675 </span>
676 </p>
677 </div>
678 <div class="card-content">
679 <div class="chart-container">
680 <canvas id="luxTimeSeriesChart"></canvas>
681 </div>
682 </div>
683 </div>
684 </div>
685 </div>
687 <div class="columns">
688 <div class="column">
689 <div class="card metrics-card">
690 <div class="card-header">
691 <p class="card-header-title permalink-header" id="solar-rad-time-series">
692 ☀️ 日射データの時系列遷移
693 <span class="permalink-icon" onclick="copyPermalink('solar-rad-time-series')">
694 <i class="fas fa-link"></i>
695 </span>
696 </p>
697 </div>
698 <div class="card-content">
699 <div class="chart-container">
700 <canvas id="solarRadTimeSeriesChart"></canvas>
701 </div>
702 </div>
703 </div>
704 </div>
705 </div>
707 <div class="columns">
708 <div class="column">
709 <div class="card metrics-card">
710 <div class="card-header">
711 <p class="card-header-title permalink-header" id="altitude-time-series">
712 📐 太陽高度の時系列遷移
713 <span class="permalink-icon" onclick="copyPermalink('altitude-time-series')">
714 <i class="fas fa-link"></i>
715 </span>
716 </p>
717 </div>
718 <div class="card-content">
719 <div class="chart-container">
720 <canvas id="altitudeTimeSeriesChart"></canvas>
721 </div>
722 </div>
723 </div>
724 </div>
725 </div>
726 </div>
727 """
730def generate_sensor_analysis_section() -> str:
731 """センサーデータ分析セクションのHTML生成"""
732 return """
733 <div class="section">
734 <h2 class="title is-4 permalink-header" id="auto-sensor-analysis">
735 <span class="icon"><i class="fas fa-robot"></i></span> センサーデータ分析(自動操作)
736 <span class="permalink-icon" onclick="copyPermalink('auto-sensor-analysis')">
737 <i class="fas fa-link"></i>
738 </span>
739 </h2>
741 <!-- 照度データ -->
742 <div class="columns">
743 <div class="column is-half">
744 <div class="card metrics-card">
745 <div class="card-header">
746 <p class="card-header-title permalink-header" id="auto-open-lux">
747 🤖 自動開操作時の照度データ ☀️
748 <span class="permalink-icon" onclick="copyPermalink('auto-open-lux')">
749 <i class="fas fa-link"></i>
750 </span>
751 </p>
752 </div>
753 <div class="card-content">
754 <div class="chart-container">
755 <canvas id="autoOpenLuxChart"></canvas>
756 </div>
757 </div>
758 </div>
759 </div>
760 <div class="column is-half">
761 <div class="card metrics-card">
762 <div class="card-header">
763 <p class="card-header-title permalink-header" id="auto-close-lux">
764 🤖 自動閉操作時の照度データ 🌙
765 <span class="permalink-icon" onclick="copyPermalink('auto-close-lux')">
766 <i class="fas fa-link"></i>
767 </span>
768 </p>
769 </div>
770 <div class="card-content">
771 <div class="chart-container">
772 <canvas id="autoCloseLuxChart"></canvas>
773 </div>
774 </div>
775 </div>
776 </div>
777 </div>
779 <!-- 日射データ -->
780 <div class="columns">
781 <div class="column is-half">
782 <div class="card metrics-card">
783 <div class="card-header">
784 <p class="card-header-title permalink-header" id="auto-open-solar-rad">
785 🤖 自動開操作時の日射データ ☀️
786 <span class="permalink-icon" onclick="copyPermalink('auto-open-solar-rad')">
787 <i class="fas fa-link"></i>
788 </span>
789 </p>
790 </div>
791 <div class="card-content">
792 <div class="chart-container">
793 <canvas id="autoOpenSolarRadChart"></canvas>
794 </div>
795 </div>
796 </div>
797 </div>
798 <div class="column is-half">
799 <div class="card metrics-card">
800 <div class="card-header">
801 <p class="card-header-title permalink-header" id="auto-close-solar-rad">
802 🤖 自動閉操作時の日射データ 🌙
803 <span class="permalink-icon" onclick="copyPermalink('auto-close-solar-rad')">
804 <i class="fas fa-link"></i>
805 </span>
806 </p>
807 </div>
808 <div class="card-content">
809 <div class="chart-container">
810 <canvas id="autoCloseSolarRadChart"></canvas>
811 </div>
812 </div>
813 </div>
814 </div>
815 </div>
817 <!-- 太陽高度データ -->
818 <div class="columns">
819 <div class="column is-half">
820 <div class="card metrics-card">
821 <div class="card-header">
822 <p class="card-header-title permalink-header" id="auto-open-altitude">
823 🤖 自動開操作時の太陽高度データ ☀️
824 <span class="permalink-icon" onclick="copyPermalink('auto-open-altitude')">
825 <i class="fas fa-link"></i>
826 </span>
827 </p>
828 </div>
829 <div class="card-content">
830 <div class="chart-container">
831 <canvas id="autoOpenAltitudeChart"></canvas>
832 </div>
833 </div>
834 </div>
835 </div>
836 <div class="column is-half">
837 <div class="card metrics-card">
838 <div class="card-header">
839 <p class="card-header-title permalink-header" id="auto-close-altitude">
840 🤖 自動閉操作時の太陽高度データ 🌙
841 <span class="permalink-icon" onclick="copyPermalink('auto-close-altitude')">
842 <i class="fas fa-link"></i>
843 </span>
844 </p>
845 </div>
846 <div class="card-content">
847 <div class="chart-container">
848 <canvas id="autoCloseAltitudeChart"></canvas>
849 </div>
850 </div>
851 </div>
852 </div>
853 </div>
854 </div>
856 <div class="section">
857 <h2 class="title is-4 permalink-header" id="manual-sensor-analysis">
858 <span class="icon"><i class="fas fa-hand-paper"></i></span> センサーデータ分析(手動操作)
859 <span class="permalink-icon" onclick="copyPermalink('manual-sensor-analysis')">
860 <i class="fas fa-link"></i>
861 </span>
862 </h2>
864 <!-- 照度データ -->
865 <div class="columns">
866 <div class="column is-half">
867 <div class="card metrics-card">
868 <div class="card-header">
869 <p class="card-header-title permalink-header" id="manual-open-lux">
870 👆 手動開操作時の照度データ ☀️
871 <span class="permalink-icon" onclick="copyPermalink('manual-open-lux')">
872 <i class="fas fa-link"></i>
873 </span>
874 </p>
875 </div>
876 <div class="card-content">
877 <div class="chart-container">
878 <canvas id="manualOpenLuxChart"></canvas>
879 </div>
880 </div>
881 </div>
882 </div>
883 <div class="column is-half">
884 <div class="card metrics-card">
885 <div class="card-header">
886 <p class="card-header-title permalink-header" id="manual-close-lux">
887 👆 手動閉操作時の照度データ 🌙
888 <span class="permalink-icon" onclick="copyPermalink('manual-close-lux')">
889 <i class="fas fa-link"></i>
890 </span>
891 </p>
892 </div>
893 <div class="card-content">
894 <div class="chart-container">
895 <canvas id="manualCloseLuxChart"></canvas>
896 </div>
897 </div>
898 </div>
899 </div>
900 </div>
902 <!-- 日射データ -->
903 <div class="columns">
904 <div class="column is-half">
905 <div class="card metrics-card">
906 <div class="card-header">
907 <p class="card-header-title permalink-header" id="manual-open-solar-rad">
908 👆 手動開操作時の日射データ ☀️
909 <span class="permalink-icon" onclick="copyPermalink('manual-open-solar-rad')">
910 <i class="fas fa-link"></i>
911 </span>
912 </p>
913 </div>
914 <div class="card-content">
915 <div class="chart-container">
916 <canvas id="manualOpenSolarRadChart"></canvas>
917 </div>
918 </div>
919 </div>
920 </div>
921 <div class="column is-half">
922 <div class="card metrics-card">
923 <div class="card-header">
924 <p class="card-header-title permalink-header" id="manual-close-solar-rad">
925 👆 手動閉操作時の日射データ 🌙
926 <span class="permalink-icon" onclick="copyPermalink('manual-close-solar-rad')">
927 <i class="fas fa-link"></i>
928 </span>
929 </p>
930 </div>
931 <div class="card-content">
932 <div class="chart-container">
933 <canvas id="manualCloseSolarRadChart"></canvas>
934 </div>
935 </div>
936 </div>
937 </div>
938 </div>
940 <!-- 太陽高度データ -->
941 <div class="columns">
942 <div class="column is-half">
943 <div class="card metrics-card">
944 <div class="card-header">
945 <p class="card-header-title permalink-header" id="manual-open-altitude">
946 👆 手動開操作時の太陽高度データ ☀️
947 <span class="permalink-icon" onclick="copyPermalink('manual-open-altitude')">
948 <i class="fas fa-link"></i>
949 </span>
950 </p>
951 </div>
952 <div class="card-content">
953 <div class="chart-container">
954 <canvas id="manualOpenAltitudeChart"></canvas>
955 </div>
956 </div>
957 </div>
958 </div>
959 <div class="column is-half">
960 <div class="card metrics-card">
961 <div class="card-header">
962 <p class="card-header-title permalink-header" id="manual-close-altitude">
963 👆 手動閉操作時の太陽高度データ 🌙
964 <span class="permalink-icon" onclick="copyPermalink('manual-close-altitude')">
965 <i class="fas fa-link"></i>
966 </span>
967 </p>
968 </div>
969 <div class="card-content">
970 <div class="chart-container">
971 <canvas id="manualCloseAltitudeChart"></canvas>
972 </div>
973 </div>
974 </div>
975 </div>
976 </div>
977 </div>
978 """
981def generate_chart_javascript() -> str:
982 """チャート生成用JavaScriptを生成"""
983 return """
984 function initializePermalinks() {
985 // ページ読み込み時にハッシュがある場合はスクロール
986 if (window.location.hash) {
987 const element = document.querySelector(window.location.hash);
988 if (element) {
989 setTimeout(() => {
990 element.scrollIntoView({ behavior: 'smooth', block: 'start' });
991 }, 500); // チャート描画完了を待つ
992 }
993 }
994 }
996 function copyPermalink(sectionId) {
997 const url = window.location.origin + window.location.pathname + '#' + sectionId;
999 // Clipboard APIを使用してURLをコピー
1000 if (navigator.clipboard && window.isSecureContext) {
1001 navigator.clipboard.writeText(url).then(() => {
1002 showCopyNotification();
1003 }).catch(err => {
1004 console.error('Failed to copy: ', err);
1005 fallbackCopyToClipboard(url);
1006 });
1007 } else {
1008 // フォールバック
1009 fallbackCopyToClipboard(url);
1010 }
1012 // URLにハッシュを設定(履歴には残さない)
1013 window.history.replaceState(null, null, '#' + sectionId);
1014 }
1016 function fallbackCopyToClipboard(text) {
1017 const textArea = document.createElement("textarea");
1018 textArea.value = text;
1019 textArea.style.position = "fixed";
1020 textArea.style.left = "-999999px";
1021 textArea.style.top = "-999999px";
1022 document.body.appendChild(textArea);
1023 textArea.focus();
1024 textArea.select();
1026 try {
1027 document.execCommand('copy');
1028 showCopyNotification();
1029 } catch (err) {
1030 console.error('Fallback: Failed to copy', err);
1031 // 最後の手段として、プロンプトでURLを表示
1032 prompt('URLをコピーしてください:', text);
1033 }
1035 document.body.removeChild(textArea);
1036 }
1038 function showCopyNotification() {
1039 // 通知要素を作成
1040 const notification = document.createElement('div');
1041 notification.textContent = 'パーマリンクをコピーしました!';
1042 notification.style.cssText = `
1043 position: fixed;
1044 top: 20px;
1045 right: 20px;
1046 background: #23d160;
1047 color: white;
1048 padding: 12px 20px;
1049 border-radius: 4px;
1050 z-index: 1000;
1051 font-size: 14px;
1052 font-weight: 500;
1053 box-shadow: 0 2px 8px rgba(0,0,0,0.15);
1054 transition: opacity 0.3s ease-in-out;
1055 `;
1057 document.body.appendChild(notification);
1059 // 3秒後にフェードアウト
1060 setTimeout(() => {
1061 notification.style.opacity = '0';
1062 setTimeout(() => {
1063 if (notification.parentNode) {
1064 document.body.removeChild(notification);
1065 }
1066 }, 300);
1067 }, 3000);
1068 }
1069 function generateTimeCharts() {
1070 // 開操作時刻ヒストグラム
1071 const openTimeHistogramCtx = document.getElementById('openTimeHistogramChart');
1072 if (openTimeHistogramCtx && chartData.open_times.length > 0) {
1073 const bins = Array.from({length: 24}, (_, i) => i);
1074 const openHist = Array(24).fill(0);
1076 chartData.open_times.forEach(time => {
1077 const hour = Math.floor(time);
1078 if (hour >= 0 && hour < 24) openHist[hour]++;
1079 });
1081 // 頻度を%に変換
1082 const total = chartData.open_times.length;
1083 const openHistPercent = openHist.map(count => total > 0 ? (count / total) * 100 : 0);
1085 new Chart(openTimeHistogramCtx, {
1086 type: 'bar',
1087 data: {
1088 labels: bins.map(h => h + ':00'),
1089 datasets: [{
1090 label: '☀️ 開操作頻度',
1091 data: openHistPercent,
1092 backgroundColor: 'rgba(255, 206, 84, 0.7)',
1093 borderColor: 'rgba(255, 206, 84, 1)',
1094 borderWidth: 1
1095 }]
1096 },
1097 options: {
1098 responsive: true,
1099 maintainAspectRatio: false,
1100 scales: {
1101 y: {
1102 beginAtZero: true,
1103 max: 100,
1104 title: {
1105 display: true,
1106 text: '頻度(%)'
1107 },
1108 ticks: {
1109 callback: function(value) {
1110 return value + '%';
1111 }
1112 }
1113 },
1114 x: {
1115 title: {
1116 display: true,
1117 text: '時刻'
1118 }
1119 }
1120 }
1121 }
1122 });
1123 }
1125 // 閉操作時刻ヒストグラム
1126 const closeTimeHistogramCtx = document.getElementById('closeTimeHistogramChart');
1127 if (closeTimeHistogramCtx && chartData.close_times.length > 0) {
1128 const bins = Array.from({length: 24}, (_, i) => i);
1129 const closeHist = Array(24).fill(0);
1131 chartData.close_times.forEach(time => {
1132 const hour = Math.floor(time);
1133 if (hour >= 0 && hour < 24) closeHist[hour]++;
1134 });
1136 // 頻度を%に変換
1137 const total = chartData.close_times.length;
1138 const closeHistPercent = closeHist.map(count => total > 0 ? (count / total) * 100 : 0);
1140 new Chart(closeTimeHistogramCtx, {
1141 type: 'bar',
1142 data: {
1143 labels: bins.map(h => h + ':00'),
1144 datasets: [{
1145 label: '🌙 閉操作頻度',
1146 data: closeHistPercent,
1147 backgroundColor: 'rgba(153, 102, 255, 0.7)',
1148 borderColor: 'rgba(153, 102, 255, 1)',
1149 borderWidth: 1
1150 }]
1151 },
1152 options: {
1153 responsive: true,
1154 maintainAspectRatio: false,
1155 scales: {
1156 y: {
1157 beginAtZero: true,
1158 max: 100,
1159 title: {
1160 display: true,
1161 text: '頻度(%)'
1162 },
1163 ticks: {
1164 callback: function(value) {
1165 return value + '%';
1166 }
1167 }
1168 },
1169 x: {
1170 title: {
1171 display: true,
1172 text: '時刻'
1173 }
1174 }
1175 }
1176 }
1177 });
1178 }
1179 }
1181 function generateTimeSeriesCharts() {
1182 // 操作時刻の時系列グラフ
1183 const timeSeriesCtx = document.getElementById('timeSeriesChart');
1184 if (timeSeriesCtx && chartData.time_series && chartData.time_series.dates.length > 0) {
1185 new Chart(timeSeriesCtx, {
1186 type: 'line',
1187 data: {
1188 labels: chartData.time_series.dates,
1189 datasets: [
1190 {
1191 label: '☀️ 開操作時刻',
1192 data: chartData.time_series.open_times,
1193 borderColor: 'rgba(255, 206, 84, 1)',
1194 backgroundColor: 'rgba(255, 206, 84, 0.1)',
1195 tension: 0.1,
1196 spanGaps: true
1197 },
1198 {
1199 label: '🌙 閉操作時刻',
1200 data: chartData.time_series.close_times,
1201 borderColor: 'rgba(153, 102, 255, 1)',
1202 backgroundColor: 'rgba(153, 102, 255, 0.1)',
1203 tension: 0.1,
1204 spanGaps: true
1205 }
1206 ]
1207 },
1208 options: {
1209 responsive: true,
1210 maintainAspectRatio: false,
1211 interaction: {
1212 mode: 'index',
1213 intersect: false
1214 },
1215 scales: {
1216 y: {
1217 beginAtZero: true,
1218 max: 24,
1219 title: {
1220 display: true,
1221 text: '時刻'
1222 },
1223 ticks: {
1224 callback: function(value) {
1225 const hour = Math.floor(value);
1226 const minute = Math.round((value - hour) * 60);
1227 return hour + ':' + (minute < 10 ? '0' : '') + minute;
1228 }
1229 }
1230 },
1231 x: {
1232 title: {
1233 display: true,
1234 text: '日付'
1235 }
1236 }
1237 }
1238 }
1239 });
1240 }
1242 // 照度の時系列グラフ
1243 const luxTimeSeriesCtx = document.getElementById('luxTimeSeriesChart');
1244 if (luxTimeSeriesCtx && chartData.time_series && chartData.time_series.dates.length > 0) {
1245 new Chart(luxTimeSeriesCtx, {
1246 type: 'line',
1247 data: {
1248 labels: chartData.time_series.dates,
1249 datasets: [
1250 {
1251 label: '☀️ 開操作時照度',
1252 data: chartData.time_series.open_lux,
1253 borderColor: 'rgba(255, 206, 84, 1)',
1254 backgroundColor: 'rgba(255, 206, 84, 0.1)',
1255 tension: 0.1,
1256 spanGaps: true
1257 },
1258 {
1259 label: '🌙 閉操作時照度',
1260 data: chartData.time_series.close_lux,
1261 borderColor: 'rgba(153, 102, 255, 1)',
1262 backgroundColor: 'rgba(153, 102, 255, 0.1)',
1263 tension: 0.1,
1264 spanGaps: true
1265 }
1266 ]
1267 },
1268 options: {
1269 responsive: true,
1270 maintainAspectRatio: false,
1271 interaction: {
1272 mode: 'index',
1273 intersect: false
1274 },
1275 scales: {
1276 y: {
1277 beginAtZero: true,
1278 title: {
1279 display: true,
1280 text: '照度(lux)'
1281 },
1282 ticks: {
1283 callback: function(value) {
1284 return value.toLocaleString();
1285 }
1286 }
1287 },
1288 x: {
1289 title: {
1290 display: true,
1291 text: '日付'
1292 }
1293 }
1294 }
1295 }
1296 });
1297 }
1299 // 日射の時系列グラフ
1300 const solarRadTimeSeriesCtx = document.getElementById('solarRadTimeSeriesChart');
1301 if (solarRadTimeSeriesCtx && chartData.time_series && chartData.time_series.dates.length > 0) {
1302 new Chart(solarRadTimeSeriesCtx, {
1303 type: 'line',
1304 data: {
1305 labels: chartData.time_series.dates,
1306 datasets: [
1307 {
1308 label: '☀️ 開操作時日射',
1309 data: chartData.time_series.open_solar_rad,
1310 borderColor: 'rgba(255, 159, 64, 1)',
1311 backgroundColor: 'rgba(255, 159, 64, 0.1)',
1312 tension: 0.1,
1313 spanGaps: true
1314 },
1315 {
1316 label: '🌙 閉操作時日射',
1317 data: chartData.time_series.close_solar_rad,
1318 borderColor: 'rgba(75, 192, 192, 1)',
1319 backgroundColor: 'rgba(75, 192, 192, 0.1)',
1320 tension: 0.1,
1321 spanGaps: true
1322 }
1323 ]
1324 },
1325 options: {
1326 responsive: true,
1327 maintainAspectRatio: false,
1328 interaction: {
1329 mode: 'index',
1330 intersect: false
1331 },
1332 scales: {
1333 y: {
1334 beginAtZero: true,
1335 title: {
1336 display: true,
1337 text: '日射(W/m²)'
1338 }
1339 },
1340 x: {
1341 title: {
1342 display: true,
1343 text: '日付'
1344 }
1345 }
1346 }
1347 }
1348 });
1349 }
1351 // 太陽高度の時系列グラフ
1352 const altitudeTimeSeriesCtx = document.getElementById('altitudeTimeSeriesChart');
1353 if (altitudeTimeSeriesCtx && chartData.time_series && chartData.time_series.dates.length > 0) {
1354 new Chart(altitudeTimeSeriesCtx, {
1355 type: 'line',
1356 data: {
1357 labels: chartData.time_series.dates,
1358 datasets: [
1359 {
1360 label: '☀️ 開操作時太陽高度',
1361 data: chartData.time_series.open_altitude,
1362 borderColor: 'rgba(255, 99, 132, 1)',
1363 backgroundColor: 'rgba(255, 99, 132, 0.1)',
1364 tension: 0.1,
1365 spanGaps: true
1366 },
1367 {
1368 label: '🌙 閉操作時太陽高度',
1369 data: chartData.time_series.close_altitude,
1370 borderColor: 'rgba(54, 162, 235, 1)',
1371 backgroundColor: 'rgba(54, 162, 235, 0.1)',
1372 tension: 0.1,
1373 spanGaps: true
1374 }
1375 ]
1376 },
1377 options: {
1378 responsive: true,
1379 maintainAspectRatio: false,
1380 interaction: {
1381 mode: 'index',
1382 intersect: false
1383 },
1384 scales: {
1385 y: {
1386 title: {
1387 display: true,
1388 text: '太陽高度(度)'
1389 }
1390 },
1391 x: {
1392 title: {
1393 display: true,
1394 text: '日付'
1395 }
1396 }
1397 }
1398 }
1399 });
1400 }
1401 }
1403 function generateAutoSensorCharts() {
1404 // ヒストグラム生成のヘルパー関数
1405 function createHistogram(data, bins) {
1406 const hist = Array(bins.length - 1).fill(0);
1407 data.forEach(value => {
1408 for (let i = 0; i < bins.length - 1; i++) {
1409 if (value >= bins[i] && value < bins[i + 1]) {
1410 hist[i]++;
1411 break;
1412 }
1413 }
1414 });
1415 return hist;
1416 }
1418 // 自動開操作時照度チャート
1419 const autoOpenLuxCtx = document.getElementById('autoOpenLuxChart');
1420 if (autoOpenLuxCtx && chartData.auto_sensor_data.open_lux.length > 0) {
1421 const minLux = Math.min(...chartData.auto_sensor_data.open_lux);
1422 const maxLux = Math.max(...chartData.auto_sensor_data.open_lux);
1423 const bins = Array.from({length: 21}, (_, i) => minLux + (maxLux - minLux) * i / 20);
1424 const hist = createHistogram(chartData.auto_sensor_data.open_lux, bins);
1425 const total = chartData.auto_sensor_data.open_lux.length;
1426 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1428 new Chart(autoOpenLuxCtx, {
1429 type: 'bar',
1430 data: {
1431 labels: bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1432 datasets: [{
1433 label: '🤖☀️ 自動開操作時照度頻度',
1434 data: histPercent,
1435 backgroundColor: 'rgba(255, 206, 84, 0.7)',
1436 borderColor: 'rgba(255, 206, 84, 1)',
1437 borderWidth: 1
1438 }]
1439 },
1440 options: {
1441 responsive: true,
1442 maintainAspectRatio: false,
1443 scales: {
1444 y: {
1445 beginAtZero: true,
1446 max: 100,
1447 title: {
1448 display: true,
1449 text: '頻度(%)'
1450 },
1451 ticks: {
1452 callback: function(value) {
1453 return value + '%';
1454 }
1455 }
1456 },
1457 x: {
1458 title: {
1459 display: true,
1460 text: '照度(lux)'
1461 }
1462 }
1463 }
1464 }
1465 });
1466 }
1468 // 自動閉操作時照度チャート
1469 const autoCloseLuxCtx = document.getElementById('autoCloseLuxChart');
1470 if (autoCloseLuxCtx && chartData.auto_sensor_data.close_lux.length > 0) {
1471 const minLux = Math.min(...chartData.auto_sensor_data.close_lux);
1472 const maxLux = Math.max(...chartData.auto_sensor_data.close_lux);
1473 const bins = Array.from({length: 21}, (_, i) => minLux + (maxLux - minLux) * i / 20);
1474 const hist = createHistogram(chartData.auto_sensor_data.close_lux, bins);
1475 const total = chartData.auto_sensor_data.close_lux.length;
1476 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1478 new Chart(autoCloseLuxCtx, {
1479 type: 'bar',
1480 data: {
1481 labels: bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1482 datasets: [{
1483 label: '🤖🌙 自動閉操作時照度頻度',
1484 data: histPercent,
1485 backgroundColor: 'rgba(153, 102, 255, 0.7)',
1486 borderColor: 'rgba(153, 102, 255, 1)',
1487 borderWidth: 1
1488 }]
1489 },
1490 options: {
1491 responsive: true,
1492 maintainAspectRatio: false,
1493 scales: {
1494 y: {
1495 beginAtZero: true,
1496 max: 100,
1497 title: {
1498 display: true,
1499 text: '頻度(%)'
1500 },
1501 ticks: {
1502 callback: function(value) {
1503 return value + '%';
1504 }
1505 }
1506 },
1507 x: {
1508 title: {
1509 display: true,
1510 text: '照度(lux)'
1511 }
1512 }
1513 }
1514 }
1515 });
1516 }
1518 // 自動開操作時日射チャート
1519 const autoOpenSolarRadCtx = document.getElementById('autoOpenSolarRadChart');
1520 if (autoOpenSolarRadCtx && chartData.auto_sensor_data.open_solar_rad.length > 0) {
1521 const minRad = Math.min(...chartData.auto_sensor_data.open_solar_rad);
1522 const maxRad = Math.max(...chartData.auto_sensor_data.open_solar_rad);
1523 const bins = Array.from({length: 21}, (_, i) => minRad + (maxRad - minRad) * i / 20);
1524 const hist = createHistogram(chartData.auto_sensor_data.open_solar_rad, bins);
1525 const total = chartData.auto_sensor_data.open_solar_rad.length;
1526 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1528 new Chart(autoOpenSolarRadCtx, {
1529 type: 'bar',
1530 data: {
1531 labels: bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1532 datasets: [{
1533 label: '🤖☀️ 自動開操作時日射頻度',
1534 data: histPercent,
1535 backgroundColor: 'rgba(255, 159, 64, 0.7)',
1536 borderColor: 'rgba(255, 159, 64, 1)',
1537 borderWidth: 1
1538 }]
1539 },
1540 options: {
1541 responsive: true,
1542 maintainAspectRatio: false,
1543 scales: {
1544 y: {
1545 beginAtZero: true,
1546 max: 100,
1547 title: {
1548 display: true,
1549 text: '頻度(%)'
1550 },
1551 ticks: {
1552 callback: function(value) {
1553 return value + '%';
1554 }
1555 }
1556 },
1557 x: {
1558 title: {
1559 display: true,
1560 text: '日射(W/m²)'
1561 }
1562 }
1563 }
1564 }
1565 });
1566 }
1568 // 自動閉操作時日射チャート
1569 const autoCloseSolarRadCtx = document.getElementById('autoCloseSolarRadChart');
1570 if (autoCloseSolarRadCtx && chartData.auto_sensor_data.close_solar_rad.length > 0) {
1571 const minRad = Math.min(...chartData.auto_sensor_data.close_solar_rad);
1572 const maxRad = Math.max(...chartData.auto_sensor_data.close_solar_rad);
1573 const bins = Array.from({length: 21}, (_, i) => minRad + (maxRad - minRad) * i / 20);
1574 const hist = createHistogram(chartData.auto_sensor_data.close_solar_rad, bins);
1575 const total = chartData.auto_sensor_data.close_solar_rad.length;
1576 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1578 new Chart(autoCloseSolarRadCtx, {
1579 type: 'bar',
1580 data: {
1581 labels: bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1582 datasets: [{
1583 label: '🤖🌙 自動閉操作時日射頻度',
1584 data: histPercent,
1585 backgroundColor: 'rgba(75, 192, 192, 0.7)',
1586 borderColor: 'rgba(75, 192, 192, 1)',
1587 borderWidth: 1
1588 }]
1589 },
1590 options: {
1591 responsive: true,
1592 maintainAspectRatio: false,
1593 scales: {
1594 y: {
1595 beginAtZero: true,
1596 max: 100,
1597 title: {
1598 display: true,
1599 text: '頻度(%)'
1600 },
1601 ticks: {
1602 callback: function(value) {
1603 return value + '%';
1604 }
1605 }
1606 },
1607 x: {
1608 title: {
1609 display: true,
1610 text: '日射(W/m²)'
1611 }
1612 }
1613 }
1614 }
1615 });
1616 }
1618 // 自動開操作時太陽高度チャート
1619 const autoOpenAltitudeCtx = document.getElementById('autoOpenAltitudeChart');
1620 if (autoOpenAltitudeCtx && chartData.auto_sensor_data.open_altitude.length > 0) {
1621 const minAlt = Math.min(...chartData.auto_sensor_data.open_altitude);
1622 const maxAlt = Math.max(...chartData.auto_sensor_data.open_altitude);
1623 const bins = Array.from({length: 21}, (_, i) => minAlt + (maxAlt - minAlt) * i / 20);
1624 const hist = createHistogram(chartData.auto_sensor_data.open_altitude, bins);
1625 const total = chartData.auto_sensor_data.open_altitude.length;
1626 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1628 new Chart(autoOpenAltitudeCtx, {
1629 type: 'bar',
1630 data: {
1631 labels: bins.slice(0, -1).map(b => Math.round(b * 10) / 10),
1632 datasets: [{
1633 label: '🤖☀️ 自動開操作時太陽高度頻度',
1634 data: histPercent,
1635 backgroundColor: 'rgba(255, 99, 132, 0.7)',
1636 borderColor: 'rgba(255, 99, 132, 1)',
1637 borderWidth: 1
1638 }]
1639 },
1640 options: {
1641 responsive: true,
1642 maintainAspectRatio: false,
1643 scales: {
1644 y: {
1645 beginAtZero: true,
1646 max: 100,
1647 title: {
1648 display: true,
1649 text: '頻度(%)'
1650 },
1651 ticks: {
1652 callback: function(value) {
1653 return value + '%';
1654 }
1655 }
1656 },
1657 x: {
1658 title: {
1659 display: true,
1660 text: '太陽高度(度)'
1661 }
1662 }
1663 }
1664 }
1665 });
1666 }
1668 // 自動閉操作時太陽高度チャート
1669 const autoCloseAltitudeCtx = document.getElementById('autoCloseAltitudeChart');
1670 if (autoCloseAltitudeCtx && chartData.auto_sensor_data.close_altitude.length > 0) {
1671 const minAlt = Math.min(...chartData.auto_sensor_data.close_altitude);
1672 const maxAlt = Math.max(...chartData.auto_sensor_data.close_altitude);
1673 const bins = Array.from({length: 21}, (_, i) => minAlt + (maxAlt - minAlt) * i / 20);
1674 const hist = createHistogram(chartData.auto_sensor_data.close_altitude, bins);
1675 const total = chartData.auto_sensor_data.close_altitude.length;
1676 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1678 new Chart(autoCloseAltitudeCtx, {
1679 type: 'bar',
1680 data: {
1681 labels: bins.slice(0, -1).map(b => Math.round(b * 10) / 10),
1682 datasets: [{
1683 label: '🤖🌙 自動閉操作時太陽高度頻度',
1684 data: histPercent,
1685 backgroundColor: 'rgba(54, 162, 235, 0.7)',
1686 borderColor: 'rgba(54, 162, 235, 1)',
1687 borderWidth: 1
1688 }]
1689 },
1690 options: {
1691 responsive: true,
1692 maintainAspectRatio: false,
1693 scales: {
1694 y: {
1695 beginAtZero: true,
1696 max: 100,
1697 title: {
1698 display: true,
1699 text: '頻度(%)'
1700 },
1701 ticks: {
1702 callback: function(value) {
1703 return value + '%';
1704 }
1705 }
1706 },
1707 x: {
1708 title: {
1709 display: true,
1710 text: '太陽高度(度)'
1711 }
1712 }
1713 }
1714 }
1715 });
1716 }
1717 }
1719 function generateManualSensorCharts() {
1720 // ヒストグラム生成のヘルパー関数
1721 function createHistogram(data, bins) {
1722 const hist = Array(bins.length - 1).fill(0);
1723 data.forEach(value => {
1724 for (let i = 0; i < bins.length - 1; i++) {
1725 if (value >= bins[i] && value < bins[i + 1]) {
1726 hist[i]++;
1727 break;
1728 }
1729 }
1730 });
1731 return hist;
1732 }
1734 // 手動開操作時照度チャート
1735 const manualOpenLuxCtx = document.getElementById('manualOpenLuxChart');
1736 if (manualOpenLuxCtx && chartData.manual_sensor_data &&
1737 chartData.manual_sensor_data.open_lux &&
1738 chartData.manual_sensor_data.open_lux.length > 0) {
1739 const minLux = Math.min(...chartData.manual_sensor_data.open_lux);
1740 const maxLux = Math.max(...chartData.manual_sensor_data.open_lux);
1741 const bins = Array.from({length: 21}, (_, i) => minLux + (maxLux - minLux) * i / 20);
1742 const hist = createHistogram(chartData.manual_sensor_data.open_lux, bins);
1743 const total = chartData.manual_sensor_data.open_lux.length;
1744 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1746 new Chart(manualOpenLuxCtx, {
1747 type: 'bar',
1748 data: {
1749 labels: bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1750 datasets: [{
1751 label: '👆☀️ 手動開操作時照度頻度',
1752 data: histPercent,
1753 backgroundColor: 'rgba(255, 206, 84, 0.7)',
1754 borderColor: 'rgba(255, 206, 84, 1)',
1755 borderWidth: 1
1756 }]
1757 },
1758 options: {
1759 responsive: true,
1760 maintainAspectRatio: false,
1761 scales: {
1762 y: {
1763 beginAtZero: true,
1764 max: 100,
1765 title: {
1766 display: true,
1767 text: '頻度(%)'
1768 },
1769 ticks: {
1770 callback: function(value) {
1771 return value + '%';
1772 }
1773 }
1774 },
1775 x: {
1776 title: {
1777 display: true,
1778 text: '照度(lux)'
1779 }
1780 }
1781 }
1782 }
1783 });
1784 }
1786 // 手動閉操作時照度チャート
1787 const manualCloseLuxCtx = document.getElementById('manualCloseLuxChart');
1788 if (manualCloseLuxCtx && chartData.manual_sensor_data &&
1789 chartData.manual_sensor_data.close_lux &&
1790 chartData.manual_sensor_data.close_lux.length > 0) {
1791 const minLux = Math.min(...chartData.manual_sensor_data.close_lux);
1792 const maxLux = Math.max(...chartData.manual_sensor_data.close_lux);
1793 const bins = Array.from({length: 21}, (_, i) => minLux + (maxLux - minLux) * i / 20);
1794 const hist = createHistogram(chartData.manual_sensor_data.close_lux, bins);
1795 const total = chartData.manual_sensor_data.close_lux.length;
1796 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1798 new Chart(manualCloseLuxCtx, {
1799 type: 'bar',
1800 data: {
1801 labels: bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1802 datasets: [{
1803 label: '👆🌙 手動閉操作時照度頻度',
1804 data: histPercent,
1805 backgroundColor: 'rgba(153, 102, 255, 0.7)',
1806 borderColor: 'rgba(153, 102, 255, 1)',
1807 borderWidth: 1
1808 }]
1809 },
1810 options: {
1811 responsive: true,
1812 maintainAspectRatio: false,
1813 scales: {
1814 y: {
1815 beginAtZero: true,
1816 max: 100,
1817 title: {
1818 display: true,
1819 text: '頻度(%)'
1820 },
1821 ticks: {
1822 callback: function(value) {
1823 return value + '%';
1824 }
1825 }
1826 },
1827 x: {
1828 title: {
1829 display: true,
1830 text: '照度(lux)'
1831 }
1832 }
1833 }
1834 }
1835 });
1836 }
1838 // 手動開操作時日射チャート
1839 const manualOpenSolarRadCtx = document.getElementById('manualOpenSolarRadChart');
1840 if (manualOpenSolarRadCtx && chartData.manual_sensor_data &&
1841 chartData.manual_sensor_data.open_solar_rad &&
1842 chartData.manual_sensor_data.open_solar_rad.length > 0) {
1843 const minRad = Math.min(...chartData.manual_sensor_data.open_solar_rad);
1844 const maxRad = Math.max(...chartData.manual_sensor_data.open_solar_rad);
1845 const bins = Array.from({length: 21}, (_, i) => minRad + (maxRad - minRad) * i / 20);
1846 const hist = createHistogram(chartData.manual_sensor_data.open_solar_rad, bins);
1847 const total = chartData.manual_sensor_data.open_solar_rad.length;
1848 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1850 new Chart(manualOpenSolarRadCtx, {
1851 type: 'bar',
1852 data: {
1853 labels: bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1854 datasets: [{
1855 label: '👆☀️ 手動開操作時日射頻度',
1856 data: histPercent,
1857 backgroundColor: 'rgba(255, 159, 64, 0.7)',
1858 borderColor: 'rgba(255, 159, 64, 1)',
1859 borderWidth: 1
1860 }]
1861 },
1862 options: {
1863 responsive: true,
1864 maintainAspectRatio: false,
1865 scales: {
1866 y: {
1867 beginAtZero: true,
1868 max: 100,
1869 title: {
1870 display: true,
1871 text: '頻度(%)'
1872 },
1873 ticks: {
1874 callback: function(value) {
1875 return value + '%';
1876 }
1877 }
1878 },
1879 x: {
1880 title: {
1881 display: true,
1882 text: '日射(W/m²)'
1883 }
1884 }
1885 }
1886 }
1887 });
1888 }
1890 // 手動閉操作時日射チャート
1891 const manualCloseSolarRadCtx = document.getElementById('manualCloseSolarRadChart');
1892 if (manualCloseSolarRadCtx && chartData.manual_sensor_data &&
1893 chartData.manual_sensor_data.close_solar_rad &&
1894 chartData.manual_sensor_data.close_solar_rad.length > 0) {
1895 const minRad = Math.min(...chartData.manual_sensor_data.close_solar_rad);
1896 const maxRad = Math.max(...chartData.manual_sensor_data.close_solar_rad);
1897 const bins = Array.from({length: 21}, (_, i) => minRad + (maxRad - minRad) * i / 20);
1898 const hist = createHistogram(chartData.manual_sensor_data.close_solar_rad, bins);
1899 const total = chartData.manual_sensor_data.close_solar_rad.length;
1900 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1902 new Chart(manualCloseSolarRadCtx, {
1903 type: 'bar',
1904 data: {
1905 labels: bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1906 datasets: [{
1907 label: '👆🌙 手動閉操作時日射頻度',
1908 data: histPercent,
1909 backgroundColor: 'rgba(75, 192, 192, 0.7)',
1910 borderColor: 'rgba(75, 192, 192, 1)',
1911 borderWidth: 1
1912 }]
1913 },
1914 options: {
1915 responsive: true,
1916 maintainAspectRatio: false,
1917 scales: {
1918 y: {
1919 beginAtZero: true,
1920 max: 100,
1921 title: {
1922 display: true,
1923 text: '頻度(%)'
1924 },
1925 ticks: {
1926 callback: function(value) {
1927 return value + '%';
1928 }
1929 }
1930 },
1931 x: {
1932 title: {
1933 display: true,
1934 text: '日射(W/m²)'
1935 }
1936 }
1937 }
1938 }
1939 });
1940 }
1942 // 手動開操作時太陽高度チャート
1943 const manualOpenAltitudeCtx = document.getElementById('manualOpenAltitudeChart');
1944 if (manualOpenAltitudeCtx && chartData.manual_sensor_data &&
1945 chartData.manual_sensor_data.open_altitude &&
1946 chartData.manual_sensor_data.open_altitude.length > 0) {
1947 const minAlt = Math.min(...chartData.manual_sensor_data.open_altitude);
1948 const maxAlt = Math.max(...chartData.manual_sensor_data.open_altitude);
1949 const bins = Array.from({length: 21}, (_, i) => minAlt + (maxAlt - minAlt) * i / 20);
1950 const hist = createHistogram(chartData.manual_sensor_data.open_altitude, bins);
1951 const total = chartData.manual_sensor_data.open_altitude.length;
1952 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1954 new Chart(manualOpenAltitudeCtx, {
1955 type: 'bar',
1956 data: {
1957 labels: bins.slice(0, -1).map(b => Math.round(b * 10) / 10),
1958 datasets: [{
1959 label: '👆☀️ 手動開操作時太陽高度頻度',
1960 data: histPercent,
1961 backgroundColor: 'rgba(255, 99, 132, 0.7)',
1962 borderColor: 'rgba(255, 99, 132, 1)',
1963 borderWidth: 1
1964 }]
1965 },
1966 options: {
1967 responsive: true,
1968 maintainAspectRatio: false,
1969 scales: {
1970 y: {
1971 beginAtZero: true,
1972 max: 100,
1973 title: {
1974 display: true,
1975 text: '頻度(%)'
1976 },
1977 ticks: {
1978 callback: function(value) {
1979 return value + '%';
1980 }
1981 }
1982 },
1983 x: {
1984 title: {
1985 display: true,
1986 text: '太陽高度(度)'
1987 }
1988 }
1989 }
1990 }
1991 });
1992 }
1994 // 手動閉操作時太陽高度チャート
1995 const manualCloseAltitudeCtx = document.getElementById('manualCloseAltitudeChart');
1996 if (manualCloseAltitudeCtx && chartData.manual_sensor_data &&
1997 chartData.manual_sensor_data.close_altitude &&
1998 chartData.manual_sensor_data.close_altitude.length > 0) {
1999 const minAlt = Math.min(...chartData.manual_sensor_data.close_altitude);
2000 const maxAlt = Math.max(...chartData.manual_sensor_data.close_altitude);
2001 const bins = Array.from({length: 21}, (_, i) => minAlt + (maxAlt - minAlt) * i / 20);
2002 const hist = createHistogram(chartData.manual_sensor_data.close_altitude, bins);
2003 const total = chartData.manual_sensor_data.close_altitude.length;
2004 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
2006 new Chart(manualCloseAltitudeCtx, {
2007 type: 'bar',
2008 data: {
2009 labels: bins.slice(0, -1).map(b => Math.round(b * 10) / 10),
2010 datasets: [{
2011 label: '👆🌙 手動閉操作時太陽高度頻度',
2012 data: histPercent,
2013 backgroundColor: 'rgba(54, 162, 235, 0.7)',
2014 borderColor: 'rgba(54, 162, 235, 1)',
2015 borderWidth: 1
2016 }]
2017 },
2018 options: {
2019 responsive: true,
2020 maintainAspectRatio: false,
2021 scales: {
2022 y: {
2023 beginAtZero: true,
2024 max: 100,
2025 title: {
2026 display: true,
2027 text: '頻度(%)'
2028 },
2029 ticks: {
2030 callback: function(value) {
2031 return value + '%';
2032 }
2033 }
2034 },
2035 x: {
2036 title: {
2037 display: true,
2038 text: '太陽高度(度)'
2039 }
2040 }
2041 }
2042 }
2043 });
2044 }
2045 }
2046 """