Ken-INOUE commited on
Commit
fe4fb5e
·
1 Parent(s): d54d1ee

Implement initial project structure and setup

Browse files
Files changed (1) hide show
  1. app.py +478 -0
app.py ADDED
@@ -0,0 +1,478 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import pandas as pd
3
+ import numpy as np
4
+ import os
5
+ import re
6
+ from typing import Dict, Tuple, List, Optional
7
+ import plotly.graph_objects as go
8
+
9
+ # ======================================
10
+ # 設定(添付CSVの既定パス:必要に応じて変更可)
11
+ # ======================================
12
+ DEFAULT_CSV_PATH = "/mnt/data/mock_data_id_9999.csv"
13
+
14
+ # ======================================
15
+ # ユーティリティ
16
+ # ======================================
17
+ def normalize(s: str) -> str:
18
+ return str(s).replace("\u3000", " ").replace("\n", "").replace("\r", "").strip()
19
+
20
+ def try_read_csv_3header(path_or_file) -> pd.DataFrame:
21
+ """
22
+ 3行ヘッダーCSVを読み込む(cp932/utf-8-sig フォールバック)。
23
+ 1列目は timestamp として datetime 変換。
24
+ 2列目以降は (ID, ItemName, ProcessName) の3段。
25
+ """
26
+ last_err = None
27
+ for enc in ["cp932", "utf-8-sig", "utf-8"]:
28
+ try:
29
+ df = pd.read_csv(path_or_file, header=[0, 1, 2], encoding=enc)
30
+ break
31
+ except Exception as e:
32
+ last_err = e
33
+ df = None
34
+ if df is None:
35
+ raise last_err
36
+
37
+ # 先頭列を timestamp に
38
+ ts = pd.to_datetime(df.iloc[:, 0], errors="coerce")
39
+ df = df.drop(df.columns[0], axis=1)
40
+ df.insert(0, "timestamp", ts)
41
+
42
+ # 列名はタプルのまま保持(timestampは str)
43
+ # ただし内部処理用に文字列連結も作成できるように関数を用意
44
+ return df
45
+
46
+ def col_tuple_to_str(col) -> str:
47
+ if isinstance(col, tuple):
48
+ return "_".join([str(x) for x in col if x])
49
+ return str(col)
50
+
51
+ def build_index_maps(df: pd.DataFrame):
52
+ """
53
+ プロセス(3行目=タプルの3つ目)→ 該当列情報 の辞書を作る。
54
+ 各列は (col_tuple, id, item, process, col_str)
55
+ """
56
+ process_map = {}
57
+ for col in df.columns:
58
+ if col == "timestamp":
59
+ continue
60
+ if isinstance(col, tuple) and len(col) >= 3:
61
+ col_id, item_name, process_name = str(col[0]), str(col[1]), str(col[2])
62
+ else:
63
+ # 非タプル(安全策)
64
+ parts = str(col).split("_")
65
+ if len(parts) >= 3:
66
+ col_id, item_name, process_name = parts[0], "_".join(parts[1:-1]), parts[-1]
67
+ else:
68
+ # プロセスが分からない列はスキップ
69
+ continue
70
+ rec = {
71
+ "col_tuple": col,
72
+ "id": col_id,
73
+ "item": item_name,
74
+ "process": process_name,
75
+ "col_str": col_tuple_to_str(col),
76
+ }
77
+ process_map.setdefault(process_name, []).append(rec)
78
+ # プロセス候補・アイテム候補を返すために使う
79
+ processes = sorted(list(process_map.keys()), key=lambda x: normalize(x))
80
+ return process_map, processes
81
+
82
+ def extract_measure_tag(item_name: str) -> str:
83
+ """
84
+ 項目名末尾の計測項目タグを抽出。
85
+ 例:
86
+ "処理水 有機物 分析値 [mg/L]" → "mg/L"
87
+ "原水 TOC" → "TOC"
88
+ "導電率(電気伝導度) [mS/cm]" → "mS/cm"
89
+ 優先順:
90
+ 1) [...] の中身
91
+ 2) 全角/半角スペース区切りの末尾語(英字混在や記号含む)
92
+ """
93
+ s = normalize(item_name)
94
+ m = re.search(r"\[([^\[\]]+)\]\s*$", s)
95
+ if m:
96
+ return m.group(1).strip()
97
+ # 角括弧がなければ末尾語
98
+ tokens = re.split(r"\s+", s)
99
+ if tokens:
100
+ return tokens[-1]
101
+ return s
102
+
103
+ # ======================================
104
+ # しきい値ハンドリング
105
+ # ======================================
106
+ def try_read_thresholds_excel(file) -> Optional[pd.DataFrame]:
107
+ """
108
+ しきい値Excel(任意)を読み込み。
109
+ 想定カラム: ColumnID, ItemName, ProcessNo_ProcessName, LL, L, H, HH, Important(任意)
110
+ """
111
+ if file is None:
112
+ return None
113
+ df = pd.read_excel(file)
114
+ df.columns = [normalize(c) for c in df.columns]
115
+ # 必須カラム確認(最低限)
116
+ needed = {"ColumnID", "ItemName", "ProcessNo_ProcessName"}
117
+ if not needed.issubset(set(df.columns)):
118
+ # 列名が違う場合の簡易吸収
119
+ rename_map = {}
120
+ for k in list(df.columns):
121
+ nk = normalize(str(k))
122
+ if nk.lower() in ["columnid", "colid", "id"]:
123
+ rename_map[k] = "ColumnID"
124
+ elif nk.lower() in ["itemname", "item", "name"]:
125
+ rename_map[k] = "ItemName"
126
+ elif nk.lower() in ["processno_processname", "process", "processname"]:
127
+ rename_map[k] = "ProcessNo_ProcessName"
128
+ if rename_map:
129
+ df = df.rename(columns=rename_map)
130
+ # 数値化
131
+ for c in ["LL", "L", "H", "HH"]:
132
+ if c in df.columns:
133
+ df[c] = pd.to_numeric(df[c], errors="coerce")
134
+ if "Important" in df.columns:
135
+ df["Important"] = (
136
+ df["Important"].astype(str).str.upper().map({"TRUE": True, "FALSE": False})
137
+ )
138
+ return df
139
+
140
+ def build_threshold_lookup(thr_df: Optional[pd.DataFrame]) -> Dict[Tuple[str, str, str], Tuple[float, float, float, float]]:
141
+ """
142
+ キー: (ColumnID, ItemName, ProcessNo_ProcessName) → (LL, L, H, HH)
143
+ """
144
+ lookup = {}
145
+ if thr_df is None or thr_df.empty:
146
+ return lookup
147
+ for _, r in thr_df.iterrows():
148
+ colid = normalize(str(r.get("ColumnID", "")))
149
+ item = normalize(str(r.get("ItemName", "")))
150
+ proc = normalize(str(r.get("ProcessNo_ProcessName", "")))
151
+ LL = r.get("LL", np.nan)
152
+ L = r.get("L", np.nan)
153
+ H = r.get("H", np.nan)
154
+ HH = r.get("HH", np.nan)
155
+ lookup[(colid, item, proc)] = (LL, L, H, HH)
156
+ return lookup
157
+
158
+ def auto_threshold(series: pd.Series) -> Tuple[float, float, float, float]:
159
+ """
160
+ 自動しきい値: mean ± std(LL/L/H/HH の2段に同じ幅を割当)
161
+ 例: L=mean-std, LL=mean-2std, H=mean+std, HH=mean+2std
162
+ """
163
+ s = series.dropna()
164
+ if len(s) < 5:
165
+ return (np.nan, np.nan, np.nan, np.nan)
166
+ m = float(s.mean())
167
+ sd = float(s.std(ddof=1)) if len(s) >= 2 else 0.0
168
+ return (m - 2*sd, m - sd, m + sd, m + 2*sd)
169
+
170
+ def judge_status(value, LL, L, H, HH) -> str:
171
+ if pd.notna(LL) and value <= LL:
172
+ return "LL"
173
+ if pd.notna(L) and value <= L:
174
+ return "L"
175
+ if pd.notna(HH) and value >= HH:
176
+ return "HH"
177
+ if pd.notna(H) and value >= H:
178
+ return "H"
179
+ return "OK"
180
+
181
+ # カラー(点の色):閾値逸脱を強調
182
+ STATUS_COLOR = {
183
+ "LL": "#2b6cb0", # 青系
184
+ "L": "#63b3ed", # 水色
185
+ "OK": "#a0aec0", # グレー
186
+ "H": "#f6ad55", # 橙
187
+ "HH": "#e53e3e", # 赤
188
+ }
189
+
190
+ # 線色(系列ライン):列ごとに安定色
191
+ LINE_COLOR = "#4a5568" # 濃いグレー
192
+
193
+ # ======================================
194
+ # 図作成
195
+ # ======================================
196
+ def make_trend_figs(
197
+ df: pd.DataFrame,
198
+ process_map: Dict[str, List[dict]],
199
+ process_name: str,
200
+ selected_items: List[str],
201
+ thr_df: Optional[pd.DataFrame],
202
+ thr_mode: str, # "excel" or "auto"
203
+ date_min: Optional[str] = None,
204
+ date_max: Optional[str] = None,
205
+ ) -> List[go.Figure]:
206
+ """
207
+ 計測項目タグごと(extract_measure_tag)に図を分けて生成。
208
+ selected_items は「2行目(ItemName)」の値。
209
+ """
210
+ if df is None or process_name is None or process_name == "":
211
+ return []
212
+
213
+ # 対象プロセスの列レコード
214
+ recs = process_map.get(process_name, [])
215
+ if not recs:
216
+ return []
217
+
218
+ # 2行目(ItemName)で絞り込み
219
+ selected_items_set = set([normalize(x) for x in (selected_items or [])])
220
+ recs = [r for r in recs if normalize(r["item"]) in selected_items_set]
221
+ if not recs:
222
+ return []
223
+
224
+ # 日付範囲フィルタ
225
+ dfw = df.copy()
226
+ if date_min:
227
+ dfw = dfw[dfw["timestamp"] >= pd.to_datetime(date_min)]
228
+ if date_max:
229
+ dfw = dfw[dfw["timestamp"] <= pd.to_datetime(date_max)]
230
+ if dfw.empty:
231
+ return []
232
+
233
+ # しきい値参照
234
+ thr_lookup = build_threshold_lookup(thr_df) if thr_mode == "excel" else {}
235
+
236
+ # 測定項目タグごとにグループ化
237
+ groups: Dict[str, List[dict]] = {}
238
+ for r in recs:
239
+ tag = extract_measure_tag(r["item"])
240
+ groups.setdefault(tag, []).append(r)
241
+
242
+ figs = []
243
+ for tag, cols in groups.items():
244
+ fig = go.Figure()
245
+ # 各列を描画
246
+ for r in cols:
247
+ col = r["col_tuple"]
248
+ col_str = r["col_str"]
249
+ if col not in dfw.columns:
250
+ # まれにヘッダー崩れなど
251
+ if col_str in dfw.columns:
252
+ series = dfw[col_str]
253
+ else:
254
+ continue
255
+ else:
256
+ series = dfw[col]
257
+
258
+ # 値
259
+ x = dfw["timestamp"]
260
+ y = pd.to_numeric(series, errors="coerce")
261
+
262
+ # しきい値決定
263
+ if thr_mode == "excel":
264
+ key = (normalize(r["id"]), normalize(r["item"]), normalize(r["process"]))
265
+ LL, L, H, HH = thr_lookup.get(key, (np.nan, np.nan, np.nan, np.nan))
266
+ # Excelに見つからない場合は自動にフォールバック
267
+ if all(pd.isna(v) for v in [LL, L, H, HH]):
268
+ LL, L, H, HH = auto_threshold(y)
269
+ else:
270
+ LL, L, H, HH = auto_threshold(y)
271
+
272
+ # 状態ごとに点色を決める
273
+ colors = []
274
+ for v in y:
275
+ if pd.isna(v):
276
+ colors.append("rgba(0,0,0,0)")
277
+ else:
278
+ st = judge_status(v, LL, L, H, HH)
279
+ colors.append(STATUS_COLOR.get(st, STATUS_COLOR["OK"]))
280
+
281
+ # 下地のライン(視認性のため薄色)
282
+ fig.add_trace(go.Scatter(
283
+ x=x, y=y, mode="lines",
284
+ name=f"{r['item']} ({r['id']})",
285
+ line=dict(color=LINE_COLOR, width=1.5),
286
+ hovertemplate="%{x}<br>%{y}<extra>"+f"{r['item']} ({r['id']})"+"</extra>"
287
+ ))
288
+ # 色付きマーカーで逸脱強調
289
+ fig.add_trace(go.Scatter(
290
+ x=x, y=y, mode="markers",
291
+ name=f"{r['item']} markers",
292
+ marker=dict(size=6, color=colors),
293
+ showlegend=False,
294
+ hovertemplate="%{x}<br>%{y}<extra></extra>"
295
+ ))
296
+
297
+ # しきい値ガイド(あれば)
298
+ def add_hline(val, label):
299
+ if pd.notna(val):
300
+ fig.add_hline(y=float(val), line=dict(width=1, dash="dot"),
301
+ annotation_text=label, annotation_position="top left")
302
+
303
+ add_hline(LL, "LL")
304
+ add_hline(L, "L")
305
+ add_hline(H, "H")
306
+ add_hline(HH, "HH")
307
+
308
+ fig.update_layout(
309
+ title=f"{process_name} | 計測項目: {tag}",
310
+ xaxis_title="timestamp",
311
+ yaxis_title=tag,
312
+ legend_title="系列",
313
+ margin=dict(l=10, r=10, t=40, b=10),
314
+ hovermode="x unified",
315
+ )
316
+ figs.append(fig)
317
+
318
+ return figs
319
+
320
+ # ======================================
321
+ # グローバル状態(UI間共有)
322
+ # ======================================
323
+ G_DF: Optional[pd.DataFrame] = None
324
+ G_PROCESS_MAP = {}
325
+ G_PROCESSES = []
326
+ G_THRESHOLDS_DF: Optional[pd.DataFrame] = None
327
+
328
+ # ======================================
329
+ # コールバック
330
+ # ======================================
331
+ def initialize_default_csv():
332
+ """
333
+ 起動時にデフォルトCSVが存在すれば読み込む。
334
+ """
335
+ global G_DF, G_PROCESS_MAP, G_PROCESSES
336
+ if os.path.exists(DEFAULT_CSV_PATH):
337
+ try:
338
+ df = try_read_csv_3header(DEFAULT_CSV_PATH)
339
+ G_DF = df
340
+ G_PROCESS_MAP, G_PROCESSES = build_index_maps(df)
341
+ return f"✅ 既定CSVを読み込みました: {DEFAULT_CSV_PATH}", gr.update(choices=G_PROCESSES, value=(G_PROCESSES[0] if G_PROCESSES else None)), G_PROCESSES
342
+ except Exception as e:
343
+ return f"⚠ 既定CSV読み込み失敗: {e}", gr.update(), []
344
+ return "ℹ CSVをアップロードしてください。", gr.update(), []
345
+
346
+ def on_csv_upload(file):
347
+ """
348
+ CSVアップロード → パース → プロセス候補更新
349
+ """
350
+ global G_DF, G_PROCESS_MAP, G_PROCESSES
351
+ if file is None:
352
+ return "⚠ ファイルが選択されていません。", gr.update(choices=[]), []
353
+ try:
354
+ df = try_read_csv_3header(file.name if hasattr(file, "name") else file)
355
+ G_DF = df
356
+ G_PROCESS_MAP, G_PROCESSES = build_index_maps(df)
357
+ return f"✅ CSV読み込み: {df.shape[0]}行 × {df.shape[1]}列", gr.update(choices=G_PROCESSES, value=(G_PROCESSES[0] if G_PROCESSES else None)), G_PROCESSES
358
+ except Exception as e:
359
+ return f"❌ 読み込みエラー: {e}", gr.update(choices=[]), []
360
+
361
+ def on_thr_upload(file):
362
+ """
363
+ しきい値Excelアップロード → メモリ更新
364
+ """
365
+ global G_THRESHOLDS_DF
366
+ if file is None:
367
+ G_THRESHOLDS_DF = None
368
+ return "ℹ しきい値ファイルなし(自動しきい値が使われます)"
369
+ try:
370
+ thr = try_read_thresholds_excel(file.name if hasattr(file, "name") else file)
371
+ G_THRESHOLDS_DF = thr
372
+ return f"✅ しきい値を読み込みました({thr.shape[0]}件)"
373
+ except Exception as e:
374
+ G_THRESHOLDS_DF = None
375
+ return f"❌ しきい値読み込みエラー: {e}"
376
+
377
+ def update_items(process_name: str):
378
+ """
379
+ プロセス選択に応じて、項目(2行目)候補を返す。
380
+ """
381
+ if not process_name or process_name not in G_PROCESS_MAP:
382
+ return gr.update(choices=[], value=[])
383
+ items = sorted(list({rec["item"] for rec in G_PROCESS_MAP[process_name]}), key=lambda x: normalize(x))
384
+ # デフォルトは全選択
385
+ return gr.update(choices=items, value=items)
386
+
387
+ def render_figs(process_name: str, items: List[str], thr_mode: str, date_min, date_max):
388
+ """
389
+ 図を生成して返す(複数図)。GradioではList[plotly.Figure]を直接返せる。
390
+ """
391
+ if G_DF is None:
392
+ return "⚠ データ未読み込み", []
393
+ if not process_name:
394
+ return "⚠ プロセスを選択してください", []
395
+ if not items:
396
+ return "⚠ 項目を選択してください", []
397
+
398
+ figs = make_trend_figs(
399
+ G_DF, G_PROCESS_MAP, process_name, items, G_THRESHOLDS_DF, thr_mode, date_min, date_max
400
+ )
401
+ if not figs:
402
+ return "⚠ 図を生成できませんでした(データ無し or 条件不一致)", []
403
+ return f"✅ {process_name}: {len(figs)}枚のトレンド図を生成しました(計測項目タグごと)", figs
404
+
405
+ # ======================================
406
+ # UI
407
+ # ======================================
408
+ with gr.Blocks(css="""
409
+ .gradio-container {overflow: auto !important;}
410
+ """) as demo:
411
+ gr.Markdown("## トレンドグラフ専用アプリ(3行ヘッダー対応・プロセス別・計測項目タグ別・閾値色分け)")
412
+
413
+ with gr.Row():
414
+ csv_uploader = gr.File(label="① 時系列CSV(3行ヘッダー)", file_count="single", file_types=[".csv"])
415
+ thr_uploader = gr.File(label="② 閾値Excel(任意: LL/L/H/HH)", file_count="single", file_types=[".xlsx", ".xls"])
416
+
417
+ with gr.Row():
418
+ thr_mode = gr.Radio(
419
+ ["excel(アップロード優先・無ければ自動)", "自動(平均±標準偏差)"],
420
+ value="excel(アップロード優先・無ければ自動)",
421
+ label="しきい値モード"
422
+ )
423
+ date_min = gr.Textbox(label="抽出開始日時(任意)例: 2024-07-01 00:00")
424
+ date_max = gr.Textbox(label="抽出終了日時(任意)例: 2024-07-31 23:59")
425
+
426
+ status_csv = gr.Markdown()
427
+ status_thr = gr.Markdown()
428
+
429
+ process_dd = gr.Dropdown(label="対象プロセス(3行ヘッダーの3行目)", choices=[])
430
+ items_cb = gr.CheckboxGroup(label="表示する項目(3行ヘッダーの2行目)", choices=[], value=[])
431
+
432
+ with gr.Row():
433
+ btn_render = gr.Button("トレンド図を生成", variant="primary")
434
+
435
+ msg = gr.Markdown()
436
+ plots = gr.Plotly(label="トレンド図(計測項目タグごとに複数枚)", height=540, every=1, interactive=True, show_label=True, scale=100, container=True, visible=True, elem_id="plot_container", elem_classes=["w-full"], )
437
+
438
+ # コールバック接続
439
+ # 1) 既定CSVの自動ロード
440
+ init_msg, init_proc_update, _ = initialize_default_csv()
441
+ status_csv.value = init_msg
442
+ process_dd.value = init_proc_update.value
443
+ process_dd.choices = init_proc_update.choices
444
+
445
+ # 2) CSVアップロードで更新
446
+ csv_uploader.change(
447
+ on_csv_upload,
448
+ inputs=[csv_uploader],
449
+ outputs=[status_csv, process_dd, gr.State()],
450
+ )
451
+
452
+ # 3) 閾値アップロードで更新
453
+ thr_uploader.change(
454
+ on_thr_upload,
455
+ inputs=[thr_uploader],
456
+ outputs=[status_thr],
457
+ )
458
+
459
+ # 4) プロセス選択で項目候補更新
460
+ process_dd.change(
461
+ update_items,
462
+ inputs=[process_dd],
463
+ outputs=[items_cb],
464
+ )
465
+
466
+ # 5) 図生成
467
+ def _thr_mode_key(s):
468
+ return "excel" if s.startswith("excel") else "auto"
469
+
470
+ btn_render.click(
471
+ fn=lambda proc, items, mode, dmin, dmax: render_figs(proc, items, _thr_mode_key(mode), dmin, dmax),
472
+ inputs=[process_dd, items_cb, thr_mode, date_min, date_max],
473
+ outputs=[msg, plots],
474
+ )
475
+
476
+ if __name__ == "__main__":
477
+ # gradio>=4 で Plotly がそのままレンダリング可能
478
+ demo.launch()