-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.py
More file actions
785 lines (645 loc) · 26.1 KB
/
Copy pathapp.py
File metadata and controls
785 lines (645 loc) · 26.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
import streamlit as st
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import os
import json
from datetime import datetime
import requests
import io
try:
from .analyzer import (
analyze_position,
analyze_wave,
calculate_position_score,
calculate_score,
create_advice,
create_position_advice,
split_position_sections,
suggest_gain,
suggest_position_gain,
)
except ImportError:
from analyzer import (
analyze_position,
analyze_wave,
calculate_position_score,
calculate_score,
create_advice,
create_position_advice,
split_position_sections,
suggest_gain,
suggest_position_gain,
)
# このファイルは Streamlit の画面を作るメインアプリ。
# 役割は大きく3つ:
# 1. CSVをローカルから読む、またはAPIサーバーから取得する
# 2. 読み込んだデータを解析して、表・グラフ・アドバイスとして表示する
# 3. サイドバーに入力したPID値やAPI URLを保存して、次回起動時に復元する
def fetch_results(api_url):
"""APIサーバーから、保存済み解析結果の一覧を取得する。"""
try:
# API側の /results は、過去にアップロードされた解析結果の概要を返す。
response = requests.get(f"{api_url}/results", timeout=3.0)
if response.status_code == 200:
return response.json()
except Exception:
# サーバー未起動やURL間違いなどの場合は、画面側でエラーメッセージを出す。
pass
return None
def fetch_result_csv(api_url, timestamp_key):
"""選択された解析結果に対応するCSV本体をAPIサーバーから取得する。"""
try:
# timestamp_key は APIサーバー側で保存時に作った一意なキー。
response = requests.get(f"{api_url}/results/{timestamp_key}/csv", timeout=5.0)
if response.status_code == 200:
# pandas.read_csv が読めるように、バイト列をメモリ上のファイルに変換する。
return io.BytesIO(response.content)
except Exception:
pass
return None
st.set_page_config(page_title="総合調整補助ツール", layout="wide")
# 調整履歴をローカルに保存するCSV。
HISTORY_FILE = "history.csv"
# サイドバーの入力値を保存するJSON。
# Git管理には入れず、使う人ごとのローカル設定として扱う。
SIDEBAR_STATE_FILE = "sidebar_state.json"
# Streamlitのradioで使う選択肢。文字列を使い回すことでタイプミスを防ぐ。
DATA_SOURCE_OPTIONS = ["ローカルCSVファイルをアップロード", "サーバーと同期(最新データ)"]
# PID関連の初期値。アプリ初回起動時や、保存JSONが壊れている場合に使う。
CONTROL_PARAM_DEFAULTS = {
"speed_target": 500.0,
"kp_l": 1.00,
"ki_l": 0.00,
"kd_l": 0.00,
"kp_r": 1.00,
"ki_r": 0.00,
"kd_r": 0.00,
}
# サイドバーで保存・復元したい値の一覧。
# ここに入れたキーだけが sidebar_state.json に保存される。
SIDEBAR_DEFAULTS = {
**CONTROL_PARAM_DEFAULTS,
"data_source": DATA_SOURCE_OPTIONS[0],
"api_url": "http://localhost:8000",
}
def load_sidebar_state():
"""sidebar_state.json を読み込み、保存済みのサイドバー値を辞書で返す。"""
try:
with open(SIDEBAR_STATE_FILE, "r", encoding="utf-8") as f:
state = json.load(f)
except (OSError, json.JSONDecodeError):
# ファイルがない、またはJSONとして壊れている場合は、保存値なしとして扱う。
return {}
# JSONの中身が辞書でない場合も、想定外なので無視する。
return state if isinstance(state, dict) else {}
def save_sidebar_state():
"""現在のサイドバー入力値を sidebar_state.json に保存する。"""
state = {}
for key, default in SIDEBAR_DEFAULTS.items():
# session_state は Streamlit が画面部品の値を保持している場所。
value = st.session_state.get(key, default)
if key in CONTROL_PARAM_DEFAULTS:
# number_input の値はJSONに保存しやすいよう float にそろえる。
value = float(value)
state[key] = value
try:
with open(SIDEBAR_STATE_FILE, "w", encoding="utf-8") as f:
# ensure_ascii=False にすると、日本語もそのまま保存されて読みやすい。
json.dump(state, f, ensure_ascii=False, indent=2)
except OSError:
# 保存に失敗しても、アプリの表示自体は止めない。
pass
def init_sidebar_state():
"""アプリ起動時に、サイドバーの初期値を session_state に入れる。"""
saved_state = load_sidebar_state()
for key, default in SIDEBAR_DEFAULTS.items():
# すでにStreamlitが値を持っている場合は、それを優先する。
# これにより、画面操作中の再実行で値がリセットされない。
if key in st.session_state:
continue
# 保存済みJSONがあればその値を使い、なければデフォルト値を使う。
value = saved_state.get(key, default)
if key in CONTROL_PARAM_DEFAULTS:
try:
value = float(value)
except (TypeError, ValueError):
# 保存JSONに変な値が入っていた場合は、安全にデフォルトへ戻す。
value = default
elif key == "data_source" and value not in DATA_SOURCE_OPTIONS:
value = default
st.session_state[key] = value
st.title("総合調整補助ツール")
st.caption("PIDゲイン調整・速度応答解析・パワー解析・左右バランス調整・動画解析")
# session_state の初期化
init_sidebar_state()
# サーバーから読み込んだ履歴のうち、現在選ばれているものを覚えておくキー。
if "loaded_run_key" not in st.session_state:
st.session_state["loaded_run_key"] = None
st.sidebar.header("データソース設定")
data_source = st.sidebar.radio(
"データ入力モード",
DATA_SOURCE_OPTIONS,
key="data_source",
# 選択が変わったタイミングでJSONに保存する。
on_change=save_sidebar_state
)
api_url = st.session_state["api_url"]
results = None
run_options = {}
if data_source == "サーバーと同期(最新データ)":
api_url = st.sidebar.text_input(
"APIサーバーURL",
key="api_url",
# localhost以外のURLに変更した場合も、次回起動時に復元する。
on_change=save_sidebar_state
)
if st.sidebar.button("サーバーからデータを再読込"):
# Streamlitは上から順に再実行される仕組み。
# rerun すると、APIから最新の一覧を取り直せる。
st.rerun()
results = fetch_results(api_url)
if results:
for r in results:
try:
# APIのISO形式時刻を、人が読みやすい表示に変換する。
dt = datetime.fromisoformat(r["timestamp"])
formatted_time = dt.strftime("%Y-%m-%d %H:%M:%S")
except Exception:
# 変換できない場合は、APIから来た文字列をそのまま使う。
formatted_time = r["timestamp"]
# selectboxに表示する文字列。
# 同時に、表示文字列から元データを引けるよう run_options に保存する。
display_str = f"{formatted_time} (左スコア: {r['left_score']:.1f}, 右スコア: {r['right_score']:.1f})"
run_options[display_str] = r
# 前回選んでいた履歴がまだ存在するなら、その選択を維持する。
if "selected_run_display" in st.session_state and st.session_state["selected_run_display"] in run_options:
selected_display = st.session_state["selected_run_display"]
else:
# 初回や、前回選んだ履歴が消えていた場合は先頭を選ぶ。
selected_display = list(run_options.keys())[0] if run_options else None
if selected_display:
st.session_state["selected_run_display"] = selected_display
if selected_display:
selected_run = run_options[selected_display]
selected_key = selected_run["timestamp_key"]
st.session_state["loaded_run_key"] = selected_key
# ボタンを押したときのみサイドバーの値を同期データのもので上書きする
if st.sidebar.button("選択中データのパラメータを反映"):
# 自動で上書きすると手入力中の値が消えてしまうため、ボタン操作時だけ反映する。
st.session_state["speed_target"] = float(selected_run["speed_target"])
st.session_state["kp_l"] = float(selected_run["kp_l"])
st.session_state["ki_l"] = float(selected_run["ki_l"])
st.session_state["kd_l"] = float(selected_run["kd_l"])
st.session_state["kp_r"] = float(selected_run["kp_r"])
st.session_state["ki_r"] = float(selected_run["ki_r"])
st.session_state["kd_r"] = float(selected_run["kd_r"])
save_sidebar_state()
# session_stateを書き換えた直後に再実行し、number_input表示にも反映させる。
st.rerun()
st.sidebar.markdown("---")
st.sidebar.header("現在の制御パラメータ")
speed_target = st.sidebar.number_input(
"速度目標値",
key="speed_target",
step=50.0,
# 値が変わるたびに直前値として保存する。
on_change=save_sidebar_state
)
st.sidebar.markdown("---")
st.sidebar.subheader("左モーター")
kp_l = st.sidebar.number_input(
"Kp (左)",
key="kp_l",
step=0.001,
format="%.5f",
on_change=save_sidebar_state
)
ki_l = st.sidebar.number_input(
"Ki (左)",
key="ki_l",
step=0.0005,
format="%.5f",
on_change=save_sidebar_state
)
kd_l = st.sidebar.number_input(
"Kd (左)",
key="kd_l",
step=0.0005,
format="%.5f",
on_change=save_sidebar_state
)
st.sidebar.markdown("---")
st.sidebar.subheader("右モーター")
kp_r = st.sidebar.number_input(
"Kp (右)",
key="kp_r",
step=0.001,
format="%.5f",
on_change=save_sidebar_state
)
ki_r = st.sidebar.number_input(
"Ki (右)",
key="ki_r",
step=0.0005,
format="%.5f",
on_change=save_sidebar_state
)
kd_r = st.sidebar.number_input(
"Kd (右)",
key="kd_r",
step=0.0005,
format="%.5f",
on_change=save_sidebar_state
)
tab_speed, tab_position = st.tabs(["速度PID調整", "位置PID調整"])
# ========== タブ1: 速度PID解析 ==========
with tab_speed:
# df は解析対象のCSVデータ。
# ローカルアップロードでもサーバー同期でも、最終的にはここにDataFrameを入れる。
df = None
if data_source == "ローカルCSVファイルをアップロード":
st.header("実機データの読み込み")
# CSVファイルをユーザに選択してもらい、データを読み込む
uploaded_file = st.file_uploader(
"CSVファイルを選択してください",
type=["csv"]
)
if uploaded_file is not None:
# file_uploader の戻り値はファイル風オブジェクトなので、pandasで直接読める。
df = pd.read_csv(uploaded_file)
else: # "サーバーと同期(最新データ)"
st.header("サーバーから測定結果を同期中")
if results is None:
st.error(f"APIサーバー ({api_url}) に接続できませんでした。サーバーが起動しているか確認してください。")
st.info("※ ローカル環境での起動手順:\n`python api.py` を実行してAPIサーバーを起動してください。")
elif len(results) == 0:
st.warning("APIサーバーにはまだ解析データがアップロードされていません。")
st.info("PowerShellスクリプトやcurl等でデータを送信すると、ここに自動同期されます。")
else:
selected_display = st.selectbox(
"同期されたデータ履歴を選択してください",
options=list(run_options.keys()),
key="selected_run_display"
)
if selected_display:
# selectboxで選ばれた表示文字列から、対応するAPI結果を取り出す。
selected_run = run_options[selected_display]
selected_key = selected_run["timestamp_key"]
# CSVデータをロード
csv_bytes = fetch_result_csv(api_url, selected_key)
if csv_bytes is not None:
df = pd.read_csv(csv_bytes)
else:
st.error("サーバーからのCSVデータの取得に失敗しました。")
if df is not None:
# 速度タブで最低限必要なCSV列。
required_cols = [
"time",
"leftSpeed",
"rightSpeed",
"leftPower",
"rightPower"
]
if not all(c in df.columns for c in required_cols):
st.error(f"必要列: {required_cols}")
# これ以上処理を続けても列参照でエラーになるため、ここで止める。
st.stop()
# 左右それぞれの速度応答を解析する。
left_result = analyze_wave(
df["time"].values,
df["leftSpeed"].values,
df["leftPower"].values,
speed_target
)
right_result = analyze_wave(
df["time"].values,
df["rightSpeed"].values,
df["rightPower"].values,
speed_target
)
left_score = calculate_score(left_result, speed_target)
right_score = calculate_score(right_result, speed_target)
st.header("評価スコア")
c1, c2 = st.columns(2)
c1.metric("左スコア", f"{left_score:.1f}")
c2.metric("右スコア", f"{right_score:.1f}")
st.header("解析結果")
metrics = pd.DataFrame({
"項目": [
"最大速度",
"オーバーシュート[%]",
"定常偏差",
"立上り時間",
"標準偏差",
"ハンチング周期",
"Power飽和率[%]"
],
"左": [
left_result["max_speed"],
left_result["overshoot"],
left_result["steady_error"],
left_result["rise_time"],
left_result["stable_std"],
left_result["hunting_cycles"],
left_result["saturation_ratio"]
],
"右": [
right_result["max_speed"],
right_result["overshoot"],
right_result["steady_error"],
right_result["rise_time"],
right_result["stable_std"],
right_result["hunting_cycles"],
right_result["saturation_ratio"]
]
})
# use_container_width=True は、画面幅に合わせて表を広げる指定。
st.dataframe(metrics, use_container_width=True)
st.header("左右差分析")
left_avg = df["leftSpeed"].mean()
right_avg = df["rightSpeed"].mean()
diff_ratio = abs(left_avg - right_avg) / speed_target * 100
if diff_ratio > 5:
if left_avg < right_avg:
st.warning(f"左モーターが {diff_ratio:.1f}% 遅いです")
else:
st.warning(f"右モーターが {diff_ratio:.1f}% 遅いです")
else:
st.success("左右差は小さいです")
# 回帰直線の計算
# 左右速度差が時間とともにどう変化しているかを直線で近似する。
diff = (df["leftSpeed"] - df["rightSpeed"]).values
time = df["time"].values
regression_y_ave = diff.mean()
regression_x_ave = time.mean()
ar_Num = ((time - regression_x_ave) * (diff - regression_y_ave)).sum()
ar_Den = ((time - regression_x_ave) ** 2).sum()
# ar_Den が0の場合は、時間が全部同じなどで傾きを計算できないため0にする。
regression_a = ar_Num / ar_Den if ar_Den != 0 else 0
regression_b = regression_y_ave - regression_a * regression_x_ave
# 回帰直線を計算
regression_line = regression_a * time + regression_b
# グラフに回帰直線を追加
fig_diff = go.Figure()
fig_diff.add_trace(
go.Scatter(x=time, y=diff, name="実測値", mode="lines")
)
fig_diff.add_trace(
go.Scatter(x=time, y=regression_line, name="回帰直線", mode="lines",
line=dict(dash="dash", color="red"))
)
fig_diff.update_layout(
title="左右速度差の推移",
xaxis_title="時間",
yaxis_title="速度差",
hovermode="x unified"
)
st.plotly_chart(fig_diff, use_container_width=True)
st.header("ゲイン提案")
# 左右の解析結果をもとに、次に試すPID値を計算する。
lkp, lki, lkd = suggest_gain(
left_result,
kp_l,
ki_l,
kd_l,
speed_target
)
rkp, rki, rkd = suggest_gain(
right_result,
kp_r,
ki_r,
kd_r,
speed_target
)
col1, col2 = st.columns(2)
with col1:
st.subheader("左モーター")
st.write(f"Kp: {lkp}")
st.write(f"Ki: {lki}")
st.write(f"Kd: {lkd}")
for a in create_advice(left_result, speed_target):
st.write("- " + a)
with col2:
st.subheader("右モーター")
st.write(f"Kp: {rkp}")
st.write(f"Ki: {rki}")
st.write(f"Kd: {rkd}")
for a in create_advice(right_result, speed_target):
st.write("- " + a)
st.header("波形表示")
# 2行×2列のグラフ領域を作り、速度とPowerを左右別に表示する。
fig = make_subplots(
rows=2,
cols=2,
subplot_titles=(
"Left Speed",
"Right Speed",
"Left Power",
"Right Power"
)
)
fig.add_trace(
go.Scatter(x=df["time"], y=df["leftSpeed"]),
row=1, col=1
)
fig.add_trace(
go.Scatter(x=df["time"], y=df["rightSpeed"]),
row=1, col=2
)
fig.add_trace(
go.Scatter(x=df["time"], y=df["leftPower"]),
row=2, col=1
)
fig.add_trace(
go.Scatter(x=df["time"], y=df["rightPower"]),
row=2, col=2
)
# ターゲット値の横線を追加
fig.add_hline(y=speed_target, line_dash="dash", line_color="red",
row=1, col=1, annotation_text="Target", annotation_position="right")
fig.add_hline(y=speed_target, line_dash="dash", line_color="red",
row=1, col=2, annotation_text="Target", annotation_position="right")
st.plotly_chart(fig, use_container_width=True)
if st.button("調整履歴を保存"):
# 現在のPID値とスコアを1行分のDataFrameにする。
row = pd.DataFrame([{
"datetime": datetime.now(),
"kp_l": kp_l,
"ki_l": ki_l,
"kd_l": kd_l,
"kp_r": kp_r,
"ki_r": ki_r,
"kd_r": kd_r,
"score_l": left_score,
"score_r": right_score
}])
if os.path.exists(HISTORY_FILE):
# 既存履歴がある場合は、読み込んで新しい行を追加する。
history = pd.read_csv(HISTORY_FILE)
history = pd.concat([history, row])
else:
history = row
# index=False にすると、pandasの行番号をCSVに書き出さない。
history.to_csv(HISTORY_FILE, index=False)
st.success("履歴保存完了")
if os.path.exists(HISTORY_FILE):
# 保存済みの調整履歴があれば、表とスコア推移グラフを表示する。
st.header("調整履歴")
history = pd.read_csv(HISTORY_FILE)
st.dataframe(history, use_container_width=True)
fig_history = go.Figure()
fig_history.add_trace(
go.Scatter(
y=history["score_l"],
name="Left Score"
)
)
fig_history.add_trace(
go.Scatter(
y=history["score_r"],
name="Right Score"
)
)
st.plotly_chart(
fig_history,
use_container_width=True
)
with tab_position:
if df is None:
# 位置タブも同じCSVデータを使うため、先に速度タブ側でCSVを読み込む必要がある。
st.info("CSVを読み込んでください")
st.stop()
# target/kp/ki/kd が変わるごとに、位置制御の評価区間を分ける。
sections = split_position_sections(df)
tab_names = []
for idx, sec in enumerate(sections):
# 各区間を S1, S2 ... のタブ名で表示する。
target = sec["target"].iloc[0]
kp = sec["kp"].iloc[0]
tab_names.append(
f"S{idx+1}"
)
section_tabs = st.tabs(tab_names)
for idx, sec in enumerate(sections):
with section_tabs[idx]:
# 区間内では target は同じ想定なので、先頭行の値を代表値として使う。
target = sec["target"].iloc[0]
# 位置応答を解析する。
result = analyze_position(
sec["time"].values,
sec["currentVal"].values,
target
)
score = calculate_position_score(
result
)
# 区間に記録されていたPID値を表示・提案計算に使う。
kp = float(sec["kp"].iloc[0])
ki = float(sec["ki"].iloc[0])
kd = float(sec["kd"].iloc[0])
# 解析結果から次に試すPID値を提案する。
skp, ski, skd = suggest_position_gain(
result,
kp,
ki,
kd
)
st.metric(
"スコア",
f"{score:.1f}"
)
st.info(
f"目標値: {target} | P={kp:.5f} I={ki:.5f} D={kd:.5f}"
)
st.subheader("推奨ゲイン")
c1, c2, c3 = st.columns(3)
c1.metric(
"Kp",
f"{skp:.5f}",
f"{skp-kp:+.5f}"
)
c2.metric(
"Ki",
f"{ski:.5f}",
f"{ski-ki:+.5f}"
)
c3.metric(
"Kd",
f"{skd:.5f}",
f"{skd-kd:+.5f}"
)
st.dataframe(
pd.DataFrame(
{
"項目":[
"最大誤差",
"平均誤差",
"RMSE",
"収束時間",
"ハンチング"
],
"値":[
result["max_error"],
result["mean_error"],
result["rmse"],
result["settling_time"],
result["hunting_cycles"]
]
}
)
)
st.subheader("位置応答")
# currentVal と target を同じグラフに出し、追従の様子を見やすくする。
fig_pos = go.Figure()
fig_pos.add_trace(
go.Scatter(
x=sec["time"],
y=sec["currentVal"],
name="CurrentVal"
)
)
fig_pos.add_trace(
go.Scatter(
x=sec["time"],
y=[target] * len(sec),
name="Target",
line=dict(dash="dash")
)
)
fig_pos.update_layout(
xaxis_title="Time",
yaxis_title="Position",
hovermode="x unified"
)
st.plotly_chart(
fig_pos,
use_container_width=True
)
st.subheader("誤差波形")
# 目標値との差を別グラフにすると、収束や揺れが見やすい。
error = target - sec["currentVal"]
fig_err = go.Figure()
fig_err.add_trace(
go.Scatter(
x=sec["time"],
y=error,
name="Error"
)
)
fig_err.add_hline(
y=0,
line_dash="dash"
)
fig_err.update_layout(
xaxis_title="Time",
yaxis_title="Error",
hovermode="x unified"
)
st.plotly_chart(
fig_err,
use_container_width=True
)
st.subheader("アドバイス")
for a in create_position_advice(result):
st.write("- " + a)