main
KoyoNoguchi 1 month ago
parent c5bcb95d74
commit 15a473ce3d
  1. 2
      .gitignore
  2. 15
      README.md
  3. 30
      config/default.yaml
  4. 28
      config/experiment.yaml
  5. 1
      data/experiment_snapshots/.gitkeep
  6. 1
      data/final_results/.gitkeep
  7. 13
      docs/final_results_template.md
  8. 9
      docs/main_experiment_protocol.md
  9. 31
      docs/phase12_specification.md
  10. 18
      main.py
  11. 2
      src/__init__.py
  12. 24
      src/config_loader.py
  13. 19
      src/experiment_snapshot.py
  14. 31
      src/final_analysis_workflow.py
  15. 22
      src/final_report_builder.py
  16. 12
      src/gui/final_results_panel.py
  17. 11
      src/gui/main_window.py
  18. 9
      src/gui/operations_controller.py
  19. 25
      src/main_experiment_manager.py
  20. 35
      src/models.py
  21. 12
      src/phase12_cli.py
  22. 16
      src/presentation_assets_builder.py
  23. 5
      tests/test_experiment_snapshot.py
  24. 4
      tests/test_final_report_builder.py
  25. 6
      tests/test_main_experiment_manager.py
  26. 3
      tests/test_phase12_cli.py
  27. 5
      tests/test_presentation_assets_builder.py

2
.gitignore vendored

@ -25,6 +25,8 @@ data/analysis/*
data/gui/* data/gui/*
data/experiment_notes/* data/experiment_notes/*
data/calibration_history/* data/calibration_history/*
data/final_results/*
data/experiment_snapshots/*
!data/analysis/plots/ !data/analysis/plots/
assets/audio/* assets/audio/*
!**/.gitkeep !**/.gitkeep

@ -440,3 +440,18 @@ python main.py calibration-list
`valid_for_voice_analysis=false`は「振り向きなし」ではなく評価不能です。カメラ位置は主要な実験条件であり、PromptとControlで音声以外の位置、照明、通行方向、トリガー、閾値を可能な限り揃えてください。 `valid_for_voice_analysis=false`は「振り向きなし」ではなく評価不能です。カメラ位置は主要な実験条件であり、PromptとControlで音声以外の位置、照明、通行方向、トリガー、閾値を可能な限り揃えてください。
診断がreadyでも研究デザインの妥当性や因果関係を保証しません。少数サンプルの統計判断、高い反応率からの因果推論、ID switchの無視を避けてください。詳細は`docs/phase11_specification.md`を参照してください。 診断がreadyでも研究デザインの妥当性や因果関係を保証しません。少数サンプルの統計判断、高い反応率からの因果推論、ID switchの無視を避けてください。詳細は`docs/phase11_specification.md`を参照してください。
# Phase 12 本実験・最終結果整理
```powershell
python main.py main-progress --logs data/logs/unified --analysis data/analysis --output data/final_results
python main.py snapshot --config config/experiment.yaml --output data/experiment_snapshots
python main.py final-analysis --logs data/logs/unified --analysis data/analysis --output data/final_results
python main.py final-report --analysis data/analysis --output data/final_results
python main.py presentation-assets --analysis data/analysis --output data/final_results
```
本実験前にPilot Diagnostics、カメラ位置記録、Prompt/Control運用ルール、音声、response window、turn閾値を確認し、設定snapshotを保存してください。目標人数は初期運用値であり正式なサンプルサイズ設計ではありません。
最終レポートでも`valid_for_voice_analysis=false`を反応なしへ混ぜません。カメラ位置は主要な実験条件です。p値や効果量が得られても、観察研究上の制約から音声との因果関係を直接断定しません。
GUIにはFinal Resultsタブを追加しました。Phase 12操作はカメラ、YOLO、MediaPipe、音声を初期化しません。詳細は`docs/phase12_specification.md`を参照してください。

@ -1,6 +1,6 @@
application: application:
name: pedestrian-signage-system name: pedestrian-signage-system
version: "0.11.0" version: "0.12.0"
window_name: "Pedestrian Detection" window_name: "Pedestrian Detection"
exit_key: "q" exit_key: "q"
screenshot_key: "s" screenshot_key: "s"
@ -240,6 +240,34 @@ pilot_diagnostics:
calibration_history: calibration_history:
enabled: true enabled: true
path: data/calibration_history/calibration_history.csv path: data/calibration_history/calibration_history.csv
main_experiment:
enabled: true
target_prompt_valid_records: 30
target_control_valid_records: 30
minimum_prompt_valid_records: 20
minimum_control_valid_records: 20
planned_prompt_sessions: 3
planned_control_sessions: 3
condition_order_rule: "PromptとControlを同じ場所・時間帯で交互に実施する。"
camera_position_policy: "位置・高さ・角度を固定し、各セッションでcamera_position_noteを記録する。"
final_output_directory: data/final_results
experiment_snapshot:
enabled: true
output_directory: data/experiment_snapshots
include_git_commit: true
include_merged_config: true
final_report:
enabled: true
output_markdown: data/final_results/final_experiment_report.md
output_summary_csv: data/final_results/final_experiment_report_summary.csv
include_limitations: true
include_quality_notes: true
avoid_causal_claims: true
presentation_assets:
enabled: true
output_markdown: data/final_results/presentation_assets.md
output_tables_csv: data/final_results/presentation_tables.csv
include_slide_outline: true
display: display:
enabled: true enabled: true
show_bounding_boxes: true show_bounding_boxes: true

@ -84,6 +84,34 @@ pilot_diagnostics:
enabled: true enabled: true
target_valid_records_per_condition: 20 target_valid_records_per_condition: 20
require_camera_position_note: true require_camera_position_note: true
main_experiment:
enabled: true
target_prompt_valid_records: 30
target_control_valid_records: 30
minimum_prompt_valid_records: 20
minimum_control_valid_records: 20
planned_prompt_sessions: 3
planned_control_sessions: 3
condition_order_rule: "PromptとControlを同じ場所・時間帯で交互に実施する。"
camera_position_policy: "位置・高さ・角度を固定し、各セッションでcamera_position_noteを記録する。"
final_output_directory: data/final_results
experiment_snapshot:
enabled: true
output_directory: data/experiment_snapshots
include_git_commit: true
include_merged_config: true
final_report:
enabled: true
output_markdown: data/final_results/final_experiment_report.md
output_summary_csv: data/final_results/final_experiment_report_summary.csv
include_limitations: true
include_quality_notes: true
avoid_causal_claims: true
presentation_assets:
enabled: true
output_markdown: data/final_results/presentation_assets.md
output_tables_csv: data/final_results/presentation_tables.csv
include_slide_outline: true
evaluation_region: evaluation_region:
enabled: true enabled: true
coordinate_mode: normalized coordinate_mode: normalized

@ -0,0 +1,13 @@
# 最終結果テンプレート
## 実験概要
## 実装システム
## 実験条件
## 取得データと有効解析人数
## 反応率・反応時間・Yaw変化
## 統計検定と効果量
## 除外理由・ログ品質
## 制約
## 今後の課題
統計的有意差や高い反応率だけから因果関係を断定しない。

@ -0,0 +1,9 @@
# 本実験プロトコル
1. Pilot Diagnosticsの重大Issueを解消する。
2. Prompt/Controlを同じ場所・時間帯で交互に実施する。
3. カメラ位置・高さ・角度、音声内容・音量、trigger、response window、turn閾値を固定する。
4. 各セッションでconditionとcamera_position_noteを記録する。
5. 実験中に条件を変更しない。変更時はCalibration Historyへ記録する。
6. 実験後にanalyze、quality-check、pilot-diagnostics、main-progressを確認する。
7. 評価不能者を反応なしに含めない。

@ -0,0 +1,31 @@
# Phase 12 本実験・最終結果整理仕様
Phase 12は、パイロット診断後の固定設定で本実験データを収集し、進捗、設定スナップショット、解析、品質検査、診断、最終レポート、発表用成果物を一貫して管理する。
## 進捗
Prompt/Controlの総数、有効解析人数、セッション数、目標達成率を集計する。両条件が最低有効人数以上で、camera_position_noteが記録されている場合に最終解析可能とする。目標人数30、最低人数20は初期運用値であり、正式なサンプルサイズ設計ではない。
## 設定スナップショット
アプリバージョン、設定ファイル、可能ならgit commit、カメラ位置、音声内容・音量、trigger、response window、turn閾値、顔処理頻度、Yaw方向をYAML/Markdownで保存する。
## 最終ワークフロー
Phase 8解析、Phase 10品質確認、Phase 11診断、最終レポート、発表用成果物を順番に実行する。一部失敗は`final_workflow_result.json`へwarning/errorとして残す。カメラやYOLOは起動しない。
## 結果の扱い
有効解析者だけを反応率の分母に使い、評価不能を反応なしへ混ぜない。Prompt/Controlは音声以外のカメラ、照明、通行方向、trigger、閾値を揃える。p値、効果量、反応率差を記載しても因果関係を断定しない。
## 出力
`main_experiment_progress.csv/.md`、設定snapshot YAML/Markdown、`final_experiment_report.md`、`final_experiment_report_summary.csv`、`presentation_assets.md`、`presentation_tables.csv`、`final_workflow_result.json`を生成する。
## GUI/CLI
Final Resultsタブと`main-progress`、`snapshot`、`final-analysis`、`final-report`、`presentation-assets`を提供する。すべてオフライン処理である。
## 制約とPhase 13
本機能は研究デザインの妥当性、個人識別、ID switch解消、因果推論を保証しない。Phase 13では論文章立て、図表キャプション、最終スライド、最終成果物チェックを支援する。

@ -78,7 +78,7 @@ def build_analysis_parser() -> argparse.ArgumentParser:
def build_gui_parser() -> argparse.ArgumentParser: def build_gui_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="main.py gui", description="Phase 10 desktop experiment and quality GUI") parser = argparse.ArgumentParser(prog="main.py gui", description="Phase 12 desktop experiment and final-results GUI")
parser.add_argument("--config", type=Path, default=Path("config/experiment.yaml")) parser.add_argument("--config", type=Path, default=Path("config/experiment.yaml"))
return parser return parser
@ -95,6 +95,11 @@ def build_phase11_parser(command:str)->argparse.ArgumentParser:
elif command=="calibration-add":p.add_argument("--path",type=Path);p.add_argument("--operator",default="unknown");p.add_argument("--category",required=True);p.add_argument("--before",required=True);p.add_argument("--after",required=True);p.add_argument("--reason",required=True);p.add_argument("--report-id");p.add_argument("--notes",default="") elif command=="calibration-add":p.add_argument("--path",type=Path);p.add_argument("--operator",default="unknown");p.add_argument("--category",required=True);p.add_argument("--before",required=True);p.add_argument("--after",required=True);p.add_argument("--reason",required=True);p.add_argument("--report-id");p.add_argument("--notes",default="")
else:p.add_argument("--path",type=Path) else:p.add_argument("--path",type=Path)
return p return p
def build_phase12_parser(command:str)->argparse.ArgumentParser:
p=argparse.ArgumentParser(prog=f"main.py {command}",description="Phase 12 final experiment workflow");p.add_argument("--config",type=Path,default=Path("config/experiment.yaml"));p.add_argument("--output",type=Path,default=Path("data/final_results"))
if command in {"main-progress","final-analysis"}:p.add_argument("--logs",type=Path,default=Path("data/logs/unified"));p.add_argument("--analysis",type=Path,default=Path("data/analysis"))
elif command in {"final-report","presentation-assets"}:p.add_argument("--analysis",type=Path,default=Path("data/analysis"))
return p
def cli_overrides(args: argparse.Namespace) -> dict[str, Any]: def cli_overrides(args: argparse.Namespace) -> dict[str, Any]:
@ -169,6 +174,17 @@ def cli_overrides(args: argparse.Namespace) -> dict[str, Any]:
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
actual = list(sys.argv[1:] if argv is None else argv) actual = list(sys.argv[1:] if argv is None else argv)
if actual and actual[0] in {"main-progress","snapshot","final-analysis","final-report","presentation-assets"}:
command=actual[0];args=build_phase12_parser(command).parse_args(actual[1:]);from src.config_loader import load_config;from src.phase12_cli import assets,final_report,progress,snapshot,workflow
try:
c=load_config("config/default.yaml",args.config)
if command=="main-progress":outputs=progress(c,args.logs,args.analysis,args.output)
elif command=="snapshot":outputs=snapshot(c,args.config,args.output)
elif command=="final-report":outputs=final_report(c,args.analysis,args.output)
elif command=="presentation-assets":outputs=assets(c,args.analysis,args.output)
else:r=workflow(c,args.logs,args.analysis,args.output);outputs=[Path(x) for x in r.output_files]+[args.output/"final_workflow_result.json"]
[print(x) for x in outputs];return 0
except Exception as exc:print(f"{command} error: {exc}",file=sys.stderr);return 1
if actual and actual[0] in {"pilot-diagnostics","calibration-add","calibration-list"}: if actual and actual[0] in {"pilot-diagnostics","calibration-add","calibration-list"}:
from datetime import datetime from datetime import datetime
command=actual[0];args=build_phase11_parser(command).parse_args(actual[1:]);from src.config_loader import load_config;from src.models import CalibrationChangeRecord;from src.phase11_cli import add_calibration,list_calibrations,run_diagnostics command=actual[0];args=build_phase11_parser(command).parse_args(actual[1:]);from src.config_loader import load_config;from src.models import CalibrationChangeRecord;from src.phase11_cli import add_calibration,list_calibrations,run_diagnostics

@ -1,3 +1,3 @@
"""Phase 1-5 pedestrian detection, tracking, pose, turn, and voice system.""" """Phase 1-5 pedestrian detection, tracking, pose, turn, and voice system."""
__version__ = "0.11.0" __version__ = "0.12.0"

@ -374,6 +374,20 @@ class PilotDiagnosticsSettings:
@dataclass(frozen=True,slots=True) @dataclass(frozen=True,slots=True)
class CalibrationHistorySettings: class CalibrationHistorySettings:
enabled:bool;path:str enabled:bool;path:str
@dataclass(frozen=True,slots=True)
class MainExperimentSettings:
enabled:bool;target_prompt_valid_records:int;target_control_valid_records:int;minimum_prompt_valid_records:int
minimum_control_valid_records:int;planned_prompt_sessions:int;planned_control_sessions:int
condition_order_rule:str;camera_position_policy:str;final_output_directory:str
@dataclass(frozen=True,slots=True)
class ExperimentSnapshotSettings:
enabled:bool;output_directory:str;include_git_commit:bool;include_merged_config:bool
@dataclass(frozen=True,slots=True)
class FinalReportSettings:
enabled:bool;output_markdown:str;output_summary_csv:str;include_limitations:bool;include_quality_notes:bool;avoid_causal_claims:bool
@dataclass(frozen=True,slots=True)
class PresentationAssetsSettings:
enabled:bool;output_markdown:str;output_tables_csv:str;include_slide_outline:bool
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@ -438,6 +452,10 @@ class AppConfig:
presentation_summary: PresentationSummarySettings presentation_summary: PresentationSummarySettings
pilot_diagnostics:PilotDiagnosticsSettings pilot_diagnostics:PilotDiagnosticsSettings
calibration_history:CalibrationHistorySettings calibration_history:CalibrationHistorySettings
main_experiment:MainExperimentSettings
experiment_snapshot:ExperimentSnapshotSettings
final_report:FinalReportSettings
presentation_assets:PresentationAssetsSettings
display: DisplaySettings display: DisplaySettings
evaluation_region: EvaluationRegionSettings evaluation_region: EvaluationRegionSettings
trigger_line: TriggerLineSettings trigger_line: TriggerLineSettings
@ -676,6 +694,8 @@ def _validate(config: AppConfig) -> None:
pd=config.pilot_diagnostics pd=config.pilot_diagnostics
if (not isinstance(pd.enabled,bool) or min(pd.target_valid_records_per_condition,pd.min_valid_records_per_condition,pd.min_sessions_per_condition)<=0 or pd.min_valid_records_per_condition>pd.target_valid_records_per_condition or any(not 0<=v<=1 for v in (pd.min_valid_voice_analysis_rate,pd.max_not_evaluable_rate,pd.max_no_pose_estimated_rate)) or pd.max_condition_valid_count_ratio<1 or any(not isinstance(v,bool) for v in (pd.require_camera_position_note,pd.ready_requires_prompt_and_control))):raise ConfigurationError("pilot_diagnostics settings are invalid") if (not isinstance(pd.enabled,bool) or min(pd.target_valid_records_per_condition,pd.min_valid_records_per_condition,pd.min_sessions_per_condition)<=0 or pd.min_valid_records_per_condition>pd.target_valid_records_per_condition or any(not 0<=v<=1 for v in (pd.min_valid_voice_analysis_rate,pd.max_not_evaluable_rate,pd.max_no_pose_estimated_rate)) or pd.max_condition_valid_count_ratio<1 or any(not isinstance(v,bool) for v in (pd.require_camera_position_note,pd.ready_requires_prompt_and_control))):raise ConfigurationError("pilot_diagnostics settings are invalid")
if not isinstance(config.calibration_history.enabled,bool) or not isinstance(config.calibration_history.path,str):raise ConfigurationError("calibration_history settings are invalid") if not isinstance(config.calibration_history.enabled,bool) or not isinstance(config.calibration_history.path,str):raise ConfigurationError("calibration_history settings are invalid")
me=config.main_experiment
if any(v<=0 for v in (me.target_prompt_valid_records,me.target_control_valid_records,me.minimum_prompt_valid_records,me.minimum_control_valid_records,me.planned_prompt_sessions,me.planned_control_sessions)) or me.minimum_prompt_valid_records>me.target_prompt_valid_records or me.minimum_control_valid_records>me.target_control_valid_records:raise ConfigurationError("main_experiment settings are invalid")
def load_config( def load_config(
@ -717,6 +737,10 @@ def load_config(
presentation_summary=_make(PresentationSummarySettings,merged.get("presentation_summary"),"presentation_summary"), presentation_summary=_make(PresentationSummarySettings,merged.get("presentation_summary"),"presentation_summary"),
pilot_diagnostics=_make(PilotDiagnosticsSettings,merged.get("pilot_diagnostics"),"pilot_diagnostics"), pilot_diagnostics=_make(PilotDiagnosticsSettings,merged.get("pilot_diagnostics"),"pilot_diagnostics"),
calibration_history=_make(CalibrationHistorySettings,merged.get("calibration_history"),"calibration_history"), calibration_history=_make(CalibrationHistorySettings,merged.get("calibration_history"),"calibration_history"),
main_experiment=_make(MainExperimentSettings,merged.get("main_experiment"),"main_experiment"),
experiment_snapshot=_make(ExperimentSnapshotSettings,merged.get("experiment_snapshot"),"experiment_snapshot"),
final_report=_make(FinalReportSettings,merged.get("final_report"),"final_report"),
presentation_assets=_make(PresentationAssetsSettings,merged.get("presentation_assets"),"presentation_assets"),
display=_make(DisplaySettings, merged.get("display"), "display"), display=_make(DisplaySettings, merged.get("display"), "display"),
evaluation_region=_make(EvaluationRegionSettings, merged.get("evaluation_region"), "evaluation_region"), evaluation_region=_make(EvaluationRegionSettings, merged.get("evaluation_region"), "evaluation_region"),
trigger_line=_make(TriggerLineSettings, merged.get("trigger_line"), "trigger_line"), trigger_line=_make(TriggerLineSettings, merged.get("trigger_line"), "trigger_line"),

@ -0,0 +1,19 @@
"""Reproducible main-experiment setting snapshot."""
from __future__ import annotations
import subprocess
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
import yaml
from .config_loader import AppConfig
from .models import ExperimentSnapshot
class ExperimentSnapshotBuilder:
def __init__(self,config:AppConfig)->None:self.config=config
def build_snapshot(self,config_path:Path|None=None)->ExperimentSnapshot:
commit=None
if self.config.experiment_snapshot.include_git_commit:
try:commit=subprocess.run(["git","rev-parse","HEAD"],capture_output=True,text=True,timeout=2,check=True).stdout.strip()
except Exception:pass
return ExperimentSnapshot(f"snapshot_{datetime.now():%Y%m%d_%H%M%S}",datetime.now().astimezone().isoformat(timespec="seconds"),str(config_path or "config/experiment.yaml"),None,self.config.application.version,commit,self.config.unified_logging.camera_position_note,self.config.experiment_protocol.voice_prompt_text,self.config.audio.volume,self.config.voice_prompt.trigger_strategy,self.config.voice_prompt.response_window_sec,self.config.turn_detection.weak_threshold_deg,self.config.face.process_every_n_frames,self.config.turn_detection.signage_yaw_direction,"本実験用固定設定")
def export_yaml(self,s,output_path):output_path.parent.mkdir(parents=True,exist_ok=True);output_path.write_text(yaml.safe_dump(asdict(s),allow_unicode=True,sort_keys=False),encoding="utf-8");return output_path
def export_markdown(self,s,output_path):output_path.write_text("# 実験設定スナップショット\n\n"+"\n".join(f"- {k}: {v}" for k,v in asdict(s).items())+"\n",encoding="utf-8");return output_path

@ -0,0 +1,31 @@
"""Best-effort final offline workflow with explicit per-stage status."""
from __future__ import annotations
import json
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from .analysis_cli import run_analysis_cli
from .config_loader import AppConfig
from .final_report_builder import FinalReportBuilder
from .models import FinalAnalysisWorkflowResult
from .phase10_cli import run_quality
from .phase11_cli import run_diagnostics
from .presentation_assets_builder import PresentationAssetsBuilder
class FinalAnalysisWorkflow:
def __init__(self,config:AppConfig)->None:self.config=config
def run(self,logs:Path,analysis:Path,output:Path)->FinalAnalysisWorkflowResult:
outputs=[];warnings=[];errors=[];flags={"analysis":False,"quality":False,"diagnostics":False,"report":False,"assets":False};output.mkdir(parents=True,exist_ok=True)
try:outputs += [str(x) for x in run_analysis_cli(self.config.analysis,logs,analysis)];flags["analysis"]=True
except Exception as e:errors.append(f"analysis: {e}")
try:outputs += [str(x) for x in run_quality(self.config,logs,analysis,output)];flags["quality"]=True
except Exception as e:warnings.append(f"quality: {e}")
try:outputs += [str(x) for x in run_diagnostics(self.config,analysis,logs,output)];flags["diagnostics"]=True
except Exception as e:warnings.append(f"diagnostics: {e}")
report=None
try:b=FinalReportBuilder();report=b.build(analysis);outputs += [str(x) for x in b.export(report,output/"final_experiment_report.md",output/"final_experiment_report_summary.csv")];flags["report"]=True
except Exception as e:errors.append(f"report: {e}")
try:
if report is None:report=FinalReportBuilder().build(analysis)
b=PresentationAssetsBuilder();outputs += [str(x) for x in b.export(b.build(report,analysis),output/"presentation_assets.md",output/"presentation_tables.csv")];flags["assets"]=True
except Exception as e:warnings.append(f"assets: {e}")
result=FinalAnalysisWorkflowResult(f"workflow_{datetime.now():%Y%m%d_%H%M%S}",datetime.now().astimezone().isoformat(timespec="seconds"),str(logs),str(analysis),str(output),flags["analysis"],flags["quality"],flags["diagnostics"],flags["report"],flags["assets"],outputs,warnings,errors);(output/"final_workflow_result.json").write_text(json.dumps(asdict(result),ensure_ascii=False,indent=2),encoding="utf-8");return result

@ -0,0 +1,22 @@
"""Final descriptive report assembled from Phase 8 fixed-schema CSVs."""
from __future__ import annotations
import csv
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from .models import FinalExperimentReport
def _rows(path):
if not path.exists():return []
with path.open(encoding="utf-8-sig",newline="") as f:return list(csv.DictReader(f))
def _n(d,k):
try:return float(d[k]) if d.get(k) not in {None,""} else None
except:return None
class FinalReportBuilder:
def build(self,analysis:Path)->FinalExperimentReport:
cond={x.get("condition"):x for x in _rows(analysis/"condition_summary.csv")};p=cond.get("prompt",{});c=cond.get("control",{});test=(_rows(analysis/"response_rate_test.csv") or [{}])[0];nums={x.get("metric_name"):x for x in _rows(analysis/"numeric_comparisons.csv")};react=nums.get("reaction_time_sec",{});yaw=nums.get("max_yaw_delta_toward_signage",{});ex=sorted(_rows(analysis/"exclusion_summary.csv"),key=lambda x:float(x.get("count") or 0),reverse=True)[:5]
return FinalExperimentReport(f"final_{datetime.now():%Y%m%d_%H%M%S}",datetime.now().astimezone().isoformat(timespec="seconds"),"デジタルサイネージ声掛け実験",0,0,0,int(float(p.get("valid_voice_analysis_count") or 0)),int(float(c.get("valid_voice_analysis_count") or 0)),_n(p,"response_rate"),_n(c,"response_rate"),_n(test,"rate_difference"),_n(test,"p_value"),test.get("test_name"),_n(test,"effect_size"),_n(test,"odds_ratio"),_n(p,"mean_reaction_time_sec"),_n(c,"mean_reaction_time_sec"),_n(react,"p_value"),_n(p,"mean_max_yaw_delta"),_n(c,"mean_max_yaw_delta"),_n(yaw,"p_value"),[f"{x.get('condition')}:{x.get('exclusion_reason')} ({x.get('count')})" for x in ex],"評価不能者を反応なしに含めず、有効解析者を分母とした。","観察研究であり因果関係を断定できない。カメラ位置、照明、通行方向、ID switchの影響がある。","Prompt/Controlの記述的比較結果を示す。統計結果のみから音声効果を断定しない。")
def export(self,r:FinalExperimentReport,md:Path,csv_path:Path)->list[Path]:
md.parent.mkdir(parents=True,exist_ok=True);d=asdict(r);md.write_text("# 最終実験結果\n\n"+"\n".join(f"- **{k}**: {v}" for k,v in d.items())+"\n",encoding="utf-8")
row=dict(d);row["top_exclusion_reasons"]=" | ".join(r.top_exclusion_reasons)
with csv_path.open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=row.keys());w.writeheader();w.writerow(row)
return [md,csv_path]

@ -0,0 +1,12 @@
"""Phase 12 final experiment controls."""
from pathlib import Path
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QFormLayout,QLineEdit,QListWidget,QPushButton,QVBoxLayout,QWidget,QHBoxLayout
class FinalResultsPanel(QWidget):
operation_requested=Signal(str,str,str,str);open_requested=Signal(str)
def __init__(self)->None:
super().__init__();layout=QVBoxLayout(self);form=QFormLayout();self.logs=QLineEdit("data/logs/unified");self.analysis=QLineEdit("data/analysis");self.output=QLineEdit("data/final_results");form.addRow("Logs",self.logs);form.addRow("Analysis",self.analysis);form.addRow("Output",self.output);layout.addLayout(form);row=QHBoxLayout()
for label,op in (("Progress","main-progress"),("Snapshot","snapshot"),("Run final analysis","final-analysis"),("Final report","final-report"),("Presentation assets","presentation-assets")):
b=QPushButton(label);b.clicked.connect(lambda checked=False,x=op:self.operation_requested.emit(x,self.logs.text(),self.analysis.text(),self.output.text()));row.addWidget(b)
layout.addLayout(row);self.files=QListWidget();self.files.itemDoubleClicked.connect(lambda i:self.open_requested.emit(i.text()));layout.addWidget(self.files)
def set_outputs(self,paths:list[Path])->None:self.files.clear();self.files.addItems([str(p) for p in paths])

@ -11,6 +11,7 @@ from .gui_models import GuiExperimentRequest
from .log_panel import LogPanel from .log_panel import LogPanel
from .operations_controller import OperationsController from .operations_controller import OperationsController
from .diagnostics_panel import DiagnosticsPanel from .diagnostics_panel import DiagnosticsPanel
from .final_results_panel import FinalResultsPanel
from .quality_panel import QualityPanel from .quality_panel import QualityPanel
from .status_panel import StatusPanel from .status_panel import StatusPanel
from .video_widget import VideoWidget from .video_widget import VideoWidget
@ -29,12 +30,12 @@ class _Bridge(QObject):
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self,gui_settings)->None: def __init__(self,gui_settings)->None:
super().__init__();self.setWindowTitle("Pedestrian Signage Research System - Phase 10");self.resize(1200,800);self.bridge=_Bridge();self.experiment=ExperimentController(preview_max_fps=gui_settings.preview_max_fps);self.analysis=AnalysisController();self.operations=OperationsController();self._paused=False;self._last_request=None super().__init__();self.setWindowTitle("Pedestrian Signage Research System - Phase 12");self.resize(1200,800);self.bridge=_Bridge();self.experiment=ExperimentController(preview_max_fps=gui_settings.preview_max_fps);self.analysis=AnalysisController();self.operations=OperationsController();self._paused=False;self._last_request=None
self.experiment.set_frame_callback(lambda f,m:self.bridge.frame.emit(f,m));self.experiment.set_status_callback(lambda m:self.bridge.status.emit(m));self.experiment.set_error_callback(self.bridge.error.emit);self.experiment.set_finished_callback(self.bridge.finished.emit) self.experiment.set_frame_callback(lambda f,m:self.bridge.frame.emit(f,m));self.experiment.set_status_callback(lambda m:self.bridge.status.emit(m));self.experiment.set_error_callback(self.bridge.error.emit);self.experiment.set_finished_callback(self.bridge.finished.emit)
self.analysis.set_finished_callback(self.bridge.analysis_finished.emit);self.analysis.set_error_callback(self.bridge.error.emit) self.analysis.set_finished_callback(self.bridge.analysis_finished.emit);self.analysis.set_error_callback(self.bridge.error.emit)
self.operations.set_finished_callback(self.bridge.operation_finished.emit);self.operations.set_error_callback(self.bridge.error.emit) self.operations.set_finished_callback(self.bridge.operation_finished.emit);self.operations.set_error_callback(self.bridge.error.emit)
tabs=QTabWidget();tabs.addTab(self._experiment_tab(gui_settings),"Experiment");self.analysis_panel=AnalysisPanel(gui_settings.default_analysis_input,gui_settings.default_analysis_output);tabs.addTab(self.analysis_panel,"Analysis");self.quality_panel=QualityPanel();tabs.addTab(self.quality_panel,"Quality & Reports");self.diagnostics_panel=DiagnosticsPanel();tabs.addTab(self.diagnostics_panel,"Diagnostics");self.setCentralWidget(tabs) tabs=QTabWidget();tabs.addTab(self._experiment_tab(gui_settings),"Experiment");self.analysis_panel=AnalysisPanel(gui_settings.default_analysis_input,gui_settings.default_analysis_output);tabs.addTab(self.analysis_panel,"Analysis");self.quality_panel=QualityPanel();tabs.addTab(self.quality_panel,"Quality & Reports");self.diagnostics_panel=DiagnosticsPanel();tabs.addTab(self.diagnostics_panel,"Diagnostics");self.final_panel=FinalResultsPanel();tabs.addTab(self.final_panel,"Final Results");self.setCentralWidget(tabs)
self.bridge.frame.connect(lambda f,m:self.preview.set_frame(f));self.bridge.status.connect(self.status.update_metrics);self.bridge.error.connect(self._error);self.bridge.finished.connect(self._finished);self.bridge.analysis_finished.connect(self._analysis_finished);self.bridge.operation_finished.connect(self._operation_finished);self.analysis_panel.run_requested.connect(self._run_analysis);self.analysis_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)));self.quality_panel.operation_requested.connect(self._run_operation);self.quality_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)));self.diagnostics_panel.run_requested.connect(lambda a,l,o:self._run_operation("diagnostics",l,a,o));self.diagnostics_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p))) self.bridge.frame.connect(lambda f,m:self.preview.set_frame(f));self.bridge.status.connect(self.status.update_metrics);self.bridge.error.connect(self._error);self.bridge.finished.connect(self._finished);self.bridge.analysis_finished.connect(self._analysis_finished);self.bridge.operation_finished.connect(self._operation_finished);self.analysis_panel.run_requested.connect(self._run_analysis);self.analysis_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)));self.quality_panel.operation_requested.connect(self._run_operation);self.quality_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)));self.diagnostics_panel.run_requested.connect(lambda a,l,o:self._run_operation("diagnostics",l,a,o));self.diagnostics_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)));self.final_panel.operation_requested.connect(self._run_operation);self.final_panel.open_requested.connect(lambda p:open_path_in_file_manager(Path(p)))
def _experiment_tab(self,s)->QWidget: def _experiment_tab(self,s)->QWidget:
root=QWidget();layout=QHBoxLayout(root);left=QWidget();form=QFormLayout(left);self.config=QLineEdit(s.default_config_path);form.addRow("Config",self._file_row(self.config,"yaml"));self.input_type=QComboBox();self.input_type.addItems(["camera","video"]);form.addRow("Input",self.input_type);self.camera=QSpinBox();self.camera.setMinimum(0);form.addRow("Camera index",self.camera);self.video=QLineEdit();form.addRow("Video",self._file_row(self.video,"video"));self.voice=QComboBox();self.voice.addItems(["prompt","control","disabled"]);form.addRow("Voice mode",self.voice);self.audio=QLineEdit();form.addRow("Audio",self._file_row(self.audio,"audio"));self.volume=QDoubleSpinBox();self.volume.setRange(0,1);self.volume.setSingleStep(.1);self.volume.setValue(.8);form.addRow("Volume",self.volume);self.note=QLineEdit();form.addRow("Camera position note",self.note);self.yaw=QComboBox();self.yaw.addItems(["positive","negative"]);form.addRow("Signage yaw",self.yaw);self.face_every=QSpinBox();self.face_every.setMinimum(1);self.face_every.setValue(1);form.addRow("Face every",self.face_every);self.max_faces=QSpinBox();self.max_faces.setMinimum(1);self.max_faces.setValue(3);form.addRow("Max face persons",self.max_faces);self.unified=QCheckBox();self.unified.setChecked(True);form.addRow("Unified logging",self.unified) root=QWidget();layout=QHBoxLayout(root);left=QWidget();form=QFormLayout(left);self.config=QLineEdit(s.default_config_path);form.addRow("Config",self._file_row(self.config,"yaml"));self.input_type=QComboBox();self.input_type.addItems(["camera","video"]);form.addRow("Input",self.input_type);self.camera=QSpinBox();self.camera.setMinimum(0);form.addRow("Camera index",self.camera);self.video=QLineEdit();form.addRow("Video",self._file_row(self.video,"video"));self.voice=QComboBox();self.voice.addItems(["prompt","control","disabled"]);form.addRow("Voice mode",self.voice);self.audio=QLineEdit();form.addRow("Audio",self._file_row(self.audio,"audio"));self.volume=QDoubleSpinBox();self.volume.setRange(0,1);self.volume.setSingleStep(.1);self.volume.setValue(.8);form.addRow("Volume",self.volume);self.note=QLineEdit();form.addRow("Camera position note",self.note);self.yaw=QComboBox();self.yaw.addItems(["positive","negative"]);form.addRow("Signage yaw",self.yaw);self.face_every=QSpinBox();self.face_every.setMinimum(1);self.face_every.setValue(1);form.addRow("Face every",self.face_every);self.max_faces=QSpinBox();self.max_faces.setMinimum(1);self.max_faces.setValue(3);form.addRow("Max face persons",self.max_faces);self.unified=QCheckBox();self.unified.setChecked(True);form.addRow("Unified logging",self.unified)
buttons=QWidget();row=QHBoxLayout(buttons);self.start=QPushButton("Start");self.stop=QPushButton("Stop");self.pause=QPushButton("Pause");row.addWidget(self.start);row.addWidget(self.stop);row.addWidget(self.pause);form.addRow(buttons);self.start.clicked.connect(self._start);self.stop.clicked.connect(self.experiment.stop_experiment);self.pause.clicked.connect(self._pause) buttons=QWidget();row=QHBoxLayout(buttons);self.start=QPushButton("Start");self.stop=QPushButton("Stop");self.pause=QPushButton("Pause");row.addWidget(self.start);row.addWidget(self.stop);row.addWidget(self.pause);form.addRow(buttons);self.start.clicked.connect(self._start);self.stop.clicked.connect(self.experiment.stop_experiment);self.pause.clicked.connect(self._pause)
@ -70,10 +71,10 @@ class MainWindow(QMainWindow):
def _analysis_finished(self,paths)->None:self.analysis_panel.set_outputs(paths);self.logs.add_message(f"Analysis finished: {len(paths)} files") def _analysis_finished(self,paths)->None:self.analysis_panel.set_outputs(paths);self.logs.add_message(f"Analysis finished: {len(paths)} files")
@Slot(str,str,str,str) @Slot(str,str,str,str)
def _run_operation(self,operation,logs,analysis,output)->None: def _run_operation(self,operation,logs,analysis,output)->None:
try:self.operations.run(operation,Path(self.config.text()),Path(logs),Path(analysis),Path(output));self.logs.add_message(f"Phase 10 operation started: {operation}") try:self.operations.run(operation,Path(self.config.text()),Path(logs),Path(analysis),Path(output));self.logs.add_message(f"Operation started: {operation}")
except Exception as exc:self._error(str(exc)) except Exception as exc:self._error(str(exc))
@Slot(object) @Slot(object)
def _operation_finished(self,paths)->None:self.quality_panel.set_outputs(paths);self.diagnostics_panel.set_outputs(paths);self.logs.add_message(f"Offline operation outputs: {len(paths)} files") def _operation_finished(self,paths)->None:self.quality_panel.set_outputs(paths);self.diagnostics_panel.set_outputs(paths);self.final_panel.set_outputs(paths);self.logs.add_message(f"Offline operation outputs: {len(paths)} files")
def closeEvent(self,event)->None: # type: ignore[no-untyped-def] def closeEvent(self,event)->None: # type: ignore[no-untyped-def]
if self.experiment.is_running():self.experiment.stop_experiment() if self.experiment.is_running():self.experiment.stop_experiment()
event.accept() event.accept()

@ -6,6 +6,7 @@ from typing import Callable
from ..config_loader import load_config from ..config_loader import load_config
from ..phase10_cli import generate_protocol,run_pilot_report,run_presentation,run_quality from ..phase10_cli import generate_protocol,run_pilot_report,run_presentation,run_quality
from ..phase11_cli import run_diagnostics from ..phase11_cli import run_diagnostics
from ..phase12_cli import assets,final_report,progress,snapshot,workflow
class OperationsController: class OperationsController:
def __init__(self)->None:self._thread:threading.Thread|None=None;self._finished:Callable[[list[Path]],None]|None=None;self._error:Callable[[str],None]|None=None def __init__(self)->None:self._thread:threading.Thread|None=None;self._finished:Callable[[list[Path]],None]|None=None;self._error:Callable[[str],None]|None=None
def run(self,operation:str,config_path:Path,logs:Path,analysis:Path,output:Path)->None: def run(self,operation:str,config_path:Path,logs:Path,analysis:Path,output:Path)->None:
@ -14,7 +15,13 @@ class OperationsController:
def _worker(self,operation:str,config_path:Path,logs:Path,analysis:Path,output:Path)->None: def _worker(self,operation:str,config_path:Path,logs:Path,analysis:Path,output:Path)->None:
try: try:
c=load_config("config/default.yaml",config_path) c=load_config("config/default.yaml",config_path)
if operation=="diagnostics":paths=run_diagnostics(c,analysis,logs,output) if operation=="main-progress":paths=progress(c,logs,analysis,output)
elif operation=="snapshot":paths=snapshot(c,config_path,output)
elif operation=="final-report":paths=final_report(c,analysis,output)
elif operation=="presentation-assets":paths=assets(c,analysis,output)
elif operation=="final-analysis":
result=workflow(c,logs,analysis,output);paths=[Path(x) for x in result.output_files]+[output/"final_workflow_result.json"]
elif operation=="diagnostics":paths=run_diagnostics(c,analysis,logs,output)
elif operation=="quality":paths=run_quality(c,logs,analysis,output) elif operation=="quality":paths=run_quality(c,logs,analysis,output)
elif operation=="pilot":paths=[run_pilot_report(c,analysis,output/"pilot_report.md",logs)] elif operation=="pilot":paths=[run_pilot_report(c,analysis,output/"pilot_report.md",logs)]
elif operation=="presentation":paths=[run_presentation(c,analysis,output/"midterm_summary.md",logs)] elif operation=="presentation":paths=[run_presentation(c,analysis,output/"midterm_summary.md",logs)]

@ -0,0 +1,25 @@
"""Main experiment plan and collection progress management."""
from __future__ import annotations
import csv
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from .config_loader import AppConfig
from .models import MainExperimentPlan,MainExperimentProgress
def _b(v):return str(v).lower() in {"true","1","yes"}
class MainExperimentManager:
def __init__(self,config:AppConfig)->None:self.config=config
def build_plan(self)->MainExperimentPlan:
c=self.config.main_experiment;return MainExperimentPlan(f"plan_{datetime.now():%Y%m%d}","デジタルサイネージ本実験",datetime.now().astimezone().isoformat(timespec="seconds"),c.target_prompt_valid_records,c.target_control_valid_records,c.minimum_prompt_valid_records,c.minimum_control_valid_records,c.planned_prompt_sessions,c.planned_control_sessions,c.condition_order_rule,c.camera_position_policy,self.config.experiment_protocol.voice_prompt_text,self.config.audio.volume,self.config.voice_prompt.response_window_sec,self.config.turn_detection.weak_threshold_deg,"目標人数は初期運用値であり正式な検定設計ではない")
def evaluate_progress(self,logs_dir:Path,analysis_dir:Path|None=None)->MainExperimentProgress:
rows=[]
if logs_dir.exists():
for p in logs_dir.rglob("*person_analysis_table.csv"):
with p.open(encoding="utf-8-sig",newline="") as f:rows.extend(csv.DictReader(f))
p=[r for r in rows if r.get("prompt_condition")=="prompt"];c=[r for r in rows if r.get("prompt_condition")=="control"];pv=sum(_b(r.get("valid_for_voice_analysis")) for r in p);cv=sum(_b(r.get("valid_for_voice_analysis")) for r in c);ps=len({r.get("session_id") for r in p});cs=len({r.get("session_id") for r in c});cfg=self.config.main_experiment;target=pv>=cfg.target_prompt_valid_records and cv>=cfg.target_control_valid_records;camera=all(r.get("camera_position_note","").strip() for r in rows);ready=pv>=cfg.minimum_prompt_valid_records and cv>=cfg.minimum_control_valid_records and camera;issue="none" if ready else "有効人数不足" if min(pv,cv)<min(cfg.minimum_prompt_valid_records,cfg.minimum_control_valid_records) else "camera_position_note欠損";action="最終解析へ進む" if ready else "不足条件を同じ環境で追加収集する" if "人数" in issue else "カメラ位置記録を確認する"
return MainExperimentProgress(self.build_plan().plan_id,datetime.now().astimezone().isoformat(timespec="seconds"),len(p),len(c),pv,cv,ps,cs,pv/cfg.target_prompt_valid_records,cv/cfg.target_control_valid_records,pv+cv,target,ready,issue,action)
def export_progress_csv(self,p:MainExperimentProgress,path:Path)->Path:
path.parent.mkdir(parents=True,exist_ok=True)
with path.open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=asdict(p).keys());w.writeheader();w.writerow(asdict(p))
return path
def export_progress_markdown(self,p:MainExperimentProgress,path:Path)->Path:path.write_text("# 本実験収集進捗\n\n"+"\n".join(f"- {k}: {v}" for k,v in asdict(p).items())+"\n",encoding="utf-8");return path

@ -875,3 +875,38 @@ class PilotDiagnosticReport:
class CalibrationChangeRecord: class CalibrationChangeRecord:
change_id:str;created_at:str;operator:str;category:str;before_value:str;after_value:str;reason:str change_id:str;created_at:str;operator:str;category:str;before_value:str;after_value:str;reason:str
related_report_id:str|None=None;notes:str="" related_report_id:str|None=None;notes:str=""
@dataclass(frozen=True,slots=True)
class MainExperimentPlan:
plan_id:str;title:str;created_at:str;target_prompt_valid_records:int;target_control_valid_records:int
minimum_prompt_valid_records:int;minimum_control_valid_records:int;planned_prompt_sessions:int;planned_control_sessions:int
condition_order_rule:str;camera_position_policy:str;voice_prompt_text:str;voice_volume:float
response_window_sec:float;turn_weak_threshold_deg:float;notes:str
@dataclass(frozen=True,slots=True)
class MainExperimentProgress:
plan_id:str;generated_at:str;prompt_total_records:int;control_total_records:int;prompt_valid_records:int
control_valid_records:int;prompt_sessions:int;control_sessions:int;prompt_completion_rate:float|None
control_completion_rate:float|None;total_valid_records:int;target_reached:bool;ready_for_final_analysis:bool
main_issue:str;suggested_action:str
@dataclass(frozen=True,slots=True)
class ExperimentSnapshot:
snapshot_id:str;created_at:str;config_path:str;merged_config_path:str|None;application_version:str
git_commit:str|None;camera_position_note:str;voice_prompt_text:str;voice_volume:float|None
trigger_strategy:str;response_window_sec:float|None;turn_weak_threshold_deg:float|None
face_process_every_n_frames:int|None;signage_yaw_direction:str|None;notes:str
@dataclass(frozen=True,slots=True)
class FinalAnalysisWorkflowResult:
workflow_id:str;generated_at:str;input_logs_dir:str;analysis_output_dir:str;final_output_dir:str
analysis_success:bool;quality_check_success:bool;diagnostics_success:bool;final_report_success:bool
presentation_assets_success:bool;output_files:list[str];warnings:list[str];errors:list[str]
@dataclass(frozen=True,slots=True)
class FinalExperimentReport:
report_id:str;generated_at:str;experiment_title:str;total_sessions:int;prompt_sessions:int;control_sessions:int
prompt_valid_records:int;control_valid_records:int;prompt_response_rate:float|None;control_response_rate:float|None
rate_difference:float|None;response_rate_p_value:float|None;response_rate_test_name:str|None
response_rate_effect_size:float|None;odds_ratio:float|None;mean_reaction_time_prompt:float|None
mean_reaction_time_control:float|None;reaction_time_p_value:float|None;mean_yaw_delta_prompt:float|None
mean_yaw_delta_control:float|None;yaw_delta_p_value:float|None;top_exclusion_reasons:list[str]
main_quality_notes:str;limitations:str;conclusion_for_presentation:str
@dataclass(frozen=True,slots=True)
class PresentationAsset:
asset_id:str;asset_type:str;title:str;file_path:str;description:str;suggested_slide:str

@ -0,0 +1,12 @@
from pathlib import Path
from .config_loader import AppConfig
from .experiment_snapshot import ExperimentSnapshotBuilder
from .final_analysis_workflow import FinalAnalysisWorkflow
from .final_report_builder import FinalReportBuilder
from .main_experiment_manager import MainExperimentManager
from .presentation_assets_builder import PresentationAssetsBuilder
def progress(c:AppConfig,logs:Path,analysis:Path,output:Path):m=MainExperimentManager(c);p=m.evaluate_progress(logs,analysis);return [m.export_progress_csv(p,output/"main_experiment_progress.csv"),m.export_progress_markdown(p,output/"main_experiment_progress.md")]
def snapshot(c:AppConfig,config_path:Path,output:Path):b=ExperimentSnapshotBuilder(c);s=b.build_snapshot(config_path);stamp=s.snapshot_id;return [b.export_yaml(s,output/f"{stamp}.yaml"),b.export_markdown(s,output/f"{stamp}.md")]
def final_report(c:AppConfig,analysis:Path,output:Path):b=FinalReportBuilder();return b.export(b.build(analysis),output/"final_experiment_report.md",output/"final_experiment_report_summary.csv")
def assets(c:AppConfig,analysis:Path,output:Path):r=FinalReportBuilder().build(analysis);b=PresentationAssetsBuilder();return b.export(b.build(r,analysis),output/"presentation_assets.md",output/"presentation_tables.csv")
def workflow(c:AppConfig,logs:Path,analysis:Path,output:Path):return FinalAnalysisWorkflow(c).run(logs,analysis,output)

@ -0,0 +1,16 @@
"""Inventory final tables, plots, and slide-ready notes."""
from __future__ import annotations
import csv
from dataclasses import asdict
from pathlib import Path
from .models import FinalExperimentReport,PresentationAsset
class PresentationAssetsBuilder:
def build(self,report:FinalExperimentReport,analysis:Path)->list[PresentationAsset]:
assets=[PresentationAsset("table_summary","table","条件別主要結果","final_experiment_report_summary.csv","有効人数、反応率、反応時間、Yaw","結果")]
for i,p in enumerate(sorted((analysis/"plots").glob("*.png")),1):assets.append(PresentationAsset(f"figure_{i}","figure",p.stem,str(p),"Phase 8生成図","結果"))
return assets
def export(self,assets:list[PresentationAsset],md:Path,csv_path:Path)->list[Path]:
md.parent.mkdir(parents=True,exist_ok=True);md.write_text("# 発表用成果物\n\n## スライド構成\n研究目的 → システム → 条件 → 結果 → 除外・品質 → 制約 → 今後\n\n"+"\n".join(f"- {a.asset_type}: [{a.title}]({a.file_path}) — {a.description}" for a in assets)+"\n\n統計的有意差は因果関係を直接証明しない。\n",encoding="utf-8")
fields=["asset_id","asset_type","title","file_path","description","suggested_slide"]
with csv_path.open("w",encoding="utf-8-sig",newline="") as f:w=csv.DictWriter(f,fieldnames=fields);w.writeheader();w.writerows(asdict(a) for a in assets)
return [md,csv_path]

@ -0,0 +1,5 @@
from pathlib import Path
from src.config_loader import load_config
from src.experiment_snapshot import ExperimentSnapshotBuilder
def test_snapshot_exports(tmp_path:Path)->None:
b=ExperimentSnapshotBuilder(load_config("config/default.yaml",None));s=b.build_snapshot();assert s.voice_volume==.8;assert b.export_yaml(s,tmp_path/"s.yaml").exists();assert "camera_position_note" in b.export_markdown(s,tmp_path/"s.md").read_text(encoding="utf-8")

@ -0,0 +1,4 @@
from pathlib import Path
from src.final_report_builder import FinalReportBuilder
def test_empty_final_report_exports(tmp_path:Path)->None:
b=FinalReportBuilder();r=b.build(tmp_path);assert "因果関係" in r.limitations;paths=b.export(r,tmp_path/"r.md",tmp_path/"r.csv");assert paths[1].read_bytes().startswith(b"\xef\xbb\xbf")

@ -0,0 +1,6 @@
import csv
from pathlib import Path
from src.config_loader import load_config
from src.main_experiment_manager import MainExperimentManager
def test_plan_empty_progress_exports(tmp_path:Path)->None:
m=MainExperimentManager(load_config("config/default.yaml",None));assert m.build_plan().target_prompt_valid_records==30;p=m.evaluate_progress(tmp_path);assert not p.ready_for_final_analysis;assert m.export_progress_csv(p,tmp_path/"p.csv").read_bytes().startswith(b"\xef\xbb\xbf");assert m.export_progress_markdown(p,tmp_path/"p.md").exists()

@ -0,0 +1,3 @@
from main import build_phase12_parser
def test_phase12_parsers()->None:
for command in ("main-progress","snapshot","final-analysis","final-report","presentation-assets"):assert build_phase12_parser(command).parse_args([]).output

@ -0,0 +1,5 @@
from pathlib import Path
from src.final_report_builder import FinalReportBuilder
from src.presentation_assets_builder import PresentationAssetsBuilder
def test_empty_assets_export(tmp_path:Path)->None:
b=PresentationAssetsBuilder();assets=b.build(FinalReportBuilder().build(tmp_path),tmp_path);paths=b.export(assets,tmp_path/"a.md",tmp_path/"a.csv");assert "因果関係" in paths[0].read_text(encoding="utf-8")
Loading…
Cancel
Save