diff --git a/README.md b/README.md index 950db9f..87b2747 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ 公開Web版では、インストールせずに入力、計算、結果ファイルの取得を行えます。 計算は1件ずつ処理するため、混雑時は画面に表示される順番をお待ちください。 +詳細CSVは通常計算時には作成せず、「出力ファイル」タブの +「CSVファイルを出力」を押したときだけ作成します。 入力ファイルと計算成果物は一時的に保存されるため、機密情報や個人情報を含む データはアップロードしないでください。 diff --git a/apps/gradio/src/verification_app/form_app.py b/apps/gradio/src/verification_app/form_app.py index ffae645..3699481 100644 --- a/apps/gradio/src/verification_app/form_app.py +++ b/apps/gradio/src/verification_app/form_app.py @@ -277,8 +277,15 @@ def build_app( graph_status = gr.Markdown("計算完了後にグラフを表示します。") graphs = [gr.Plot(label=label) for label in GRAPH_LABELS] with gr.Tab("出力ファイル", render_children=True): + csv_status = gr.Markdown( + "計算完了後、必要な場合だけCSVファイルを出力できます。" + ) + export_csv_button = gr.Button( + "CSVファイルを出力", + interactive=False, + ) files = gr.File( - label="計算出力", + label="ダウンロード可能な計算出力", file_count="multiple", interactive=False, ) @@ -295,6 +302,26 @@ def generate_graphs(result: CalculationResult | None) -> tuple[Any, ...]: return _graph_outputs(None) return _graph_outputs(calculation_service.generate_graphs(result)) + def export_csv_files( + result: CalculationResult | None, + ) -> tuple[Any, ...]: + if result is None: + return ( + None, + "CSV出力対象の計算結果がありません。", + gr.update(interactive=False), + [], + "", + ) + exported = calculation_service.export_csv(result) + return ( + exported, + exported.csv_status, + gr.update(interactive=exported.csv_status.startswith("❌")), + list(exported.files), + exported.log, + ) + calculation_started = run.click( _calculation_started_outputs, outputs=[ @@ -304,6 +331,8 @@ def generate_graphs(result: CalculationResult | None) -> tuple[Any, ...]: log, graph_status, *graphs, + csv_status, + export_csv_button, files, ], queue=False, @@ -319,6 +348,8 @@ def generate_graphs(result: CalculationResult | None) -> tuple[Any, ...]: preview, log, graph_status, + csv_status, + export_csv_button, files, ], concurrency_limit=1, @@ -333,6 +364,20 @@ def generate_graphs(result: CalculationResult | None) -> tuple[Any, ...]: concurrency_id="calculation", show_progress="full", ) + export_csv_button.click( + export_csv_files, + inputs=result_state, + outputs=[ + result_state, + csv_status, + export_csv_button, + files, + log, + ], + concurrency_limit=1, + concurrency_id="calculation", + show_progress="full", + ) conditional_fields = tuple( field for field in form.schema.fields if field.enabled_when is not None @@ -757,6 +802,8 @@ def _calculation_started_outputs() -> tuple[Any, ...]: "", "計算完了後にグラフを生成します。", *((None,) * len(GRAPH_LABELS)), + "計算中です。CSVファイルはまだ出力できません。", + gr.update(interactive=False), [], ) @@ -777,6 +824,10 @@ def _calculation_outputs(result: CalculationResult) -> tuple[Any, ...]: result.input_data, result.log, graph_status, + result.csv_status, + gr.update( + interactive=result.succeeded and result.csv_exports is not None, + ), list(result.files), ) @@ -851,6 +902,7 @@ def _graph_outputs(result: CalculationResult | None) -> tuple[Any, ...]: def _default_service() -> CalculationService: import jjjexperiment.main from jjjexperiment.constants import version_info + from jjjexperiment.csv_artifacts import capture_csv_exports from .graphs import build_result_graphs @@ -864,6 +916,7 @@ def _default_service() -> CalculationService: workdir=output_dir, build_graphs=build_result_graphs, result_ttl_seconds=(result_ttl_seconds if result_ttl_seconds > 0 else None), + csv_export_session=capture_csv_exports, ) diff --git a/apps/gradio/src/verification_app/graphs.py b/apps/gradio/src/verification_app/graphs.py index e2c7aac..8549e9a 100644 --- a/apps/gradio/src/verification_app/graphs.py +++ b/apps/gradio/src/verification_app/graphs.py @@ -22,13 +22,22 @@ def build_result_graphs( - input_data: Mapping[str, Any], output_dir: Path, version: str + input_data: Mapping[str, Any], + output_dir: Path, + version: str, + csv_exports: Any | None = None, ) -> tuple[Figure, ...]: """Build the five graphs provided by the legacy 260715 notebook.""" prefix = f"{input_data.get('case_name', 'default')}{version}" - output2 = _read_output(output_dir / f"{prefix}_output2.csv") - output5_heating = _read_output(output_dir / f"{prefix}_H_output5.csv") - output5_cooling = _read_output(output_dir / f"{prefix}_C_output5.csv") + output2 = _read_output(output_dir / f"{prefix}_output2.csv", csv_exports) + output5_heating = _read_output( + output_dir / f"{prefix}_H_output5.csv", + csv_exports, + ) + output5_cooling = _read_output( + output_dir / f"{prefix}_C_output5.csv", + csv_exports, + ) winter = output2.loc[_WINTER] summer = output2.loc[_SUMMER] @@ -55,8 +64,14 @@ def build_result_graphs( ) -def _read_output(path: Path) -> pd.DataFrame: - frame = pd.read_csv(path, encoding="cp932", index_col=0) +def _read_output(path: Path, csv_exports: Any | None = None) -> pd.DataFrame: + dataframe = getattr(csv_exports, "dataframe", None) + captured = None if dataframe is None else dataframe(path) + frame = ( + pd.read_csv(path, encoding="cp932", index_col=0) + if captured is None + else captured.copy(deep=False) + ) frame.index = pd.to_datetime(frame.index) return frame diff --git a/apps/gradio/src/verification_app/services.py b/apps/gradio/src/verification_app/services.py index 2cfdb91..275fbc9 100644 --- a/apps/gradio/src/verification_app/services.py +++ b/apps/gradio/src/verification_app/services.py @@ -8,23 +8,40 @@ import threading import time import traceback -from contextlib import redirect_stderr, redirect_stdout -from dataclasses import dataclass, replace +from contextlib import nullcontext, redirect_stderr, redirect_stdout +from dataclasses import dataclass, field, replace from pathlib import Path from pathlib import PurePosixPath, PureWindowsPath -from typing import Any, Callable, Mapping +from typing import Any, Callable, ContextManager, Mapping from uuid import uuid4 from verification_core import build_input_data CalculationFunction = Callable[[dict[str, Any]], Any] VersionFunction = Callable[[], str] -GraphFunction = Callable[[Mapping[str, Any], Path, str], tuple[Any, ...]] +GraphFunction = Callable[[Mapping[str, Any], Path, str, Any | None], tuple[Any, ...]] RunIdFactory = Callable[[], str] +CsvExportSessionFactory = Callable[[], ContextManager[Any]] _CALCULATION_CWD_LOCK = threading.Lock() +def _captured_dataframe(csv_exports: Any | None, path: Path) -> Any | None: + if csv_exports is None: + return None + dataframe = getattr(csv_exports, "dataframe", None) + return None if dataframe is None else dataframe(path) + + +def _pending_csv_count(csv_exports: Any | None) -> int: + if csv_exports is None: + return 0 + try: + return len(csv_exports) + except TypeError: + return 0 + + @dataclass(frozen=True, slots=True) class CalculationResult: succeeded: bool @@ -37,6 +54,8 @@ class CalculationResult: graph_status: str graphs: tuple[Any, ...] annual_summary: AnnualSummary | None = None + csv_status: str = "CSVファイルはまだ出力されていません。" + csv_exports: Any | None = field(default=None, repr=False, compare=False) @dataclass(frozen=True, slots=True) @@ -62,6 +81,7 @@ def __init__( build_graphs: GraphFunction | None = None, result_ttl_seconds: float | None = 24 * 60 * 60, run_id_factory: RunIdFactory | None = None, + csv_export_session: CsvExportSessionFactory | None = None, ) -> None: self._calculate = calculate self._version_info = version_info @@ -69,6 +89,7 @@ def __init__( self._build_graphs = build_graphs self._result_ttl_seconds = result_ttl_seconds self._run_id_factory = run_id_factory or (lambda: uuid4().hex) + self._csv_export_session = csv_export_session def run( self, @@ -79,11 +100,22 @@ def run( input_data: dict[str, Any] | None = None run_id: str | None = None artifact_dir: Path | None = None + csv_exports: Any | None = None output = io.StringIO() try: input_data = build_input_data(values) _validate_case_name(input_data.get("case_name", "default")) - with _CALCULATION_CWD_LOCK, redirect_stdout(output), redirect_stderr(output): + csv_session = ( + self._csv_export_session() + if self._csv_export_session is not None + else nullcontext(None) + ) + with ( + _CALCULATION_CWD_LOCK, + csv_session as csv_exports, + redirect_stdout(output), + redirect_stderr(output), + ): output_root = (self._workdir or Path.cwd()).resolve() output_root.mkdir(parents=True, exist_ok=True) self._remove_expired_results(output_root) @@ -97,7 +129,12 @@ def run( if run_id is None or artifact_dir is None: raise RuntimeError("Calculation directory was not initialized") files = self._result_files(input_data, artifact_dir) - annual_summary = self._annual_summary(input_data, artifact_dir) + annual_summary = self._annual_summary( + input_data, + artifact_dir, + csv_exports, + ) + pending_csv_count = _pending_csv_count(csv_exports) result = CalculationResult( succeeded=True, status=f"✅ 計算が完了しました。(計算ID: {run_id[:12]})", @@ -112,11 +149,19 @@ def run( files, annual_summary, output.getvalue(), + pending_csv_count, ), files=files, graph_status="計算完了後にグラフを表示します。", graphs=(), annual_summary=annual_summary, + csv_status=( + f"CSVファイルは未出力です({pending_csv_count}件)。" + "必要な場合だけ下のボタンで作成してください。" + if pending_csv_count + else "CSVファイルは計算時に出力済みです。" + ), + csv_exports=csv_exports, ) return self.generate_graphs(result) if include_graphs else result except Exception: @@ -160,6 +205,7 @@ def generate_graphs(self, result: CalculationResult) -> CalculationResult: result.input_data, artifact_dir, self._version_info(), + result.csv_exports, ) return replace( result, @@ -171,10 +217,41 @@ def generate_graphs(self, result: CalculationResult) -> CalculationResult: return replace( result, log=log, - graph_status="❌ グラフ生成エラー(計算結果CSVは正常に作成されています)", + graph_status="❌ グラフ生成エラー(計算自体は完了しています)", graphs=(), ) + def export_csv(self, result: CalculationResult) -> CalculationResult: + """Serialize deferred CSV artifacts for a completed calculation.""" + if not result.succeeded or result.artifact_dir is None: + return replace(result, csv_status="CSV出力対象の計算結果がありません。") + if result.csv_exports is None: + return replace(result, csv_status="CSVファイルはすでに出力されています。") + try: + exported = tuple(result.csv_exports.write_all()) + artifact_dir = Path(result.artifact_dir) + files = self._result_files(result.input_data or {}, artifact_dir) + return replace( + result, + files=files, + csv_status=f"✅ {len(exported)}件のCSVファイルを出力しました。", + log=( + result.log.rstrip() + + "\n\n===== CSV出力 =====\n" + + f"{len(exported)}件のCSVファイルを作成しました。\n" + ), + ) + except Exception: + return replace( + result, + csv_status="❌ CSVファイルの出力に失敗しました。計算ログを確認してください。", + log=( + result.log.rstrip() + + "\n\n===== CSV出力エラー =====\n" + + traceback.format_exc() + ), + ) + def _result_files( self, input_data: Mapping[str, Any], @@ -191,27 +268,38 @@ def _annual_summary( self, input_data: Mapping[str, Any], artifact_dir: Path, + csv_exports: Any | None = None, ) -> AnnualSummary | None: prefix = f"{input_data.get('case_name', 'default')}{self._version_info()}" output1_path = artifact_dir / f"{prefix}_output1.csv" output2_path = artifact_dir / f"{prefix}_output2.csv" - if not output1_path.is_file() or not output2_path.is_file(): - return None - try: - with output1_path.open(encoding="cp932", newline="") as file: - output1_rows = tuple(csv.DictReader(file)) - if not output1_rows: - return None - annual = output1_rows[0] - - with output2_path.open(encoding="cp932", newline="") as file: - hourly = tuple(csv.DictReader(file)) - if not hourly: - return None - - def sum_column(name: str) -> float: - return math.fsum(float(row[name]) for row in hourly) + output1_frame = _captured_dataframe(csv_exports, output1_path) + output2_frame = _captured_dataframe(csv_exports, output2_path) + if output1_frame is not None and output2_frame is not None: + if output1_frame.empty or output2_frame.empty: + return None + annual = output1_frame.iloc[0] + + def sum_column(name: str) -> float: + return math.fsum(float(value) for value in output2_frame[name]) + + else: + if not output1_path.is_file() or not output2_path.is_file(): + return None + with output1_path.open(encoding="cp932", newline="") as file: + output1_rows = tuple(csv.DictReader(file)) + if not output1_rows: + return None + annual = output1_rows[0] + + with output2_path.open(encoding="cp932", newline="") as file: + hourly = tuple(csv.DictReader(file)) + if not hourly: + return None + + def sum_column(name: str) -> float: + return math.fsum(float(row[name]) for row in hourly) def metrics(suffix: str) -> AnnualMetrics: total_electricity = sum_column(f"E_E_{suffix}_d_t [kWh/h]") @@ -344,7 +432,13 @@ def _format_success_log( files: tuple[str, ...], annual_summary: AnnualSummary | None, engine_log: str, + pending_csv_count: int = 0, ) -> str: + output_step = ( + f"4) 詳細CSV {pending_csv_count}件をメモリに保持(画面のボタンで出力)" + if pending_csv_count + else "4) CSV、入力JSON、計算条件マニフェストを計算ID別に保存" + ) lines = [ "===== 1. 計算条件 =====", *_format_input_summary(input_data), @@ -355,22 +449,30 @@ def _format_success_log( "1) 画面入力を検証し、計算エンジン用input_dataへ変換", "2) 暖房・冷房の8760時間計算を順番に実行", "3) 一次エネルギー、未処理負荷、機器・ファン電力を集計", - "4) CSV、入力JSON、計算条件マニフェストを計算ID別に保存", + output_step, "", "===== 3. 年間結果 =====", *_format_annual_summary(annual_summary), "", "===== 4. 出力 =====", f"保存先: {artifact_dir}", - f"出力ファイル数: {len(files)}", + f"計算時に保存したファイル数: {len(files)}", *(f"- {Path(path).name}" for path in files), + *( + ( + f"詳細CSV: 未出力({pending_csv_count}件)", + "必要な場合は、画面の「CSVファイルを出力」ボタンを押してください。", + ) + if pending_csv_count + else () + ), "", "===== 5. 計算エンジン詳細ログ =====", "以下は式・分岐・機器能力などを確認するための詳細ログです。", engine_log.rstrip() or "(詳細ログの出力はありません)", "", "===== 計算完了 =====", - "すべての計算と成果物保存が正常に終了しました。", + "計算と画面表示用データの準備が正常に終了しました。", ] return "\n".join(lines) + "\n" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index be78f84..3fa92cd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -64,7 +64,10 @@ Phase 5 完了後は、このモノレポがUI、入力契約、計算エンジ 各計算にはUUIDの計算IDを付け、`VERIFICATION_OUTPUT_DIR/run-<計算ID>/`を 専用の成果物ディレクトリとして使用します。ケース名が同じでも別計算のJSON、CSV、 -マニフェスト、グラフ入力を共有しません。期限切れの計算ディレクトリは次回計算時に +マニフェスト、グラフ入力を共有しません。Web実行では完成したDataFrameを +計算結果ごとにメモリ保持し、年間集計とグラフに直接使用します。詳細CSVはユーザーが +出力ボタンを押したときだけ同じ計算IDのディレクトリへ書き出します。 +期限切れの計算ディレクトリは次回計算時に 削除し、既定保持期間は24時間です。`VERIFICATION_RESULT_TTL_SECONDS`が0以下の場合は 自動削除を無効にします。 diff --git a/packages/pyhees-jjj/src/jjjexperiment/csv_artifacts.py b/packages/pyhees-jjj/src/jjjexperiment/csv_artifacts.py new file mode 100644 index 0000000..44822a2 --- /dev/null +++ b/packages/pyhees-jjj/src/jjjexperiment/csv_artifacts.py @@ -0,0 +1,105 @@ +"""CSV artifact output that can be deferred by web frontends. + +The calculation engine still writes CSV files immediately by default. A caller +may enter :func:`capture_csv_exports` to retain the completed DataFrames in +memory and serialize them only when the user requests a download. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass, field +from pathlib import Path +from threading import Lock +from typing import Any, Iterator + +import pandas as pd + + +@dataclass(frozen=True, slots=True) +class _PendingCsv: + path: Path + frame: pd.DataFrame + args: tuple[Any, ...] + kwargs: dict[str, Any] + + +@dataclass(slots=True) +class DeferredCsvExports: + """Completed DataFrames waiting to be serialized as CSV files.""" + + _items: dict[Path, _PendingCsv] = field(default_factory=dict) + _lock: Lock = field(default_factory=Lock) + + def capture( + self, + frame: pd.DataFrame, + path: str | Path, + *args: Any, + **kwargs: Any, + ) -> None: + resolved_path = Path(path).resolve() + self._items[resolved_path] = _PendingCsv( + path=resolved_path, + # Calculation frames are occasionally extended after an intermediate + # export. Keep the exact state that an immediate to_csv call saw. + frame=frame.copy(deep=True), + args=args, + kwargs=dict(kwargs), + ) + + def dataframe(self, path: str | Path) -> pd.DataFrame | None: + item = self._items.get(Path(path).resolve()) + return None if item is None else item.frame + + def write_all(self) -> tuple[str, ...]: + """Write every pending CSV once and return the absolute paths.""" + with self._lock: + for item in self._items.values(): + if item.path.is_file(): + continue + item.path.parent.mkdir(parents=True, exist_ok=True) + item.frame.to_csv(item.path, *item.args, **item.kwargs) + return tuple(str(path) for path in sorted(self._items)) + + def __len__(self) -> int: + return len(self._items) + + def __deepcopy__(self, memo: dict[int, Any]) -> DeferredCsvExports: + # Gradio may deepcopy state values when creating a browser session. The + # captured frames are immutable after calculation, while the lock itself + # cannot be copied safely, so the session may share this collection. + memo[id(self)] = self + return self + + +_ACTIVE_EXPORTS: ContextVar[DeferredCsvExports | None] = ContextVar( + "jjjexperiment_active_csv_exports", + default=None, +) + + +@contextmanager +def capture_csv_exports() -> Iterator[DeferredCsvExports]: + """Capture engine CSV exports without changing normal engine behavior.""" + exports = DeferredCsvExports() + token = _ACTIVE_EXPORTS.set(exports) + try: + yield exports + finally: + _ACTIVE_EXPORTS.reset(token) + + +def write_dataframe_csv( + frame: pd.DataFrame, + path: str | Path, + *args: Any, + **kwargs: Any, +) -> None: + """Write now, or capture the DataFrame when a deferred session is active.""" + exports = _ACTIVE_EXPORTS.get() + if exports is None: + frame.to_csv(path, *args, **kwargs) + return + exports.capture(frame, path, *args, **kwargs) diff --git a/packages/pyhees-jjj/src/jjjexperiment/main.py b/packages/pyhees-jjj/src/jjjexperiment/main.py index f040507..db144d3 100644 --- a/packages/pyhees-jjj/src/jjjexperiment/main.py +++ b/packages/pyhees-jjj/src/jjjexperiment/main.py @@ -52,6 +52,7 @@ import jjjexperiment.constants as jjj_consts import jjjexperiment.artifact_paths as artifact_paths import jjjexperiment.common as jjj_common +from jjjexperiment.csv_artifacts import write_dataframe_csv from jjjexperiment.release import write_artifact_manifest from jjjexperiment.result import ResultSummary, SutValues from jjjexperiment.logger import LimitedLoggerAdapter as _logger # デバッグ用ロガー @@ -393,7 +394,8 @@ def _get_heating_fan_model(heat_ac_setting, V_hs_dsgn_H, heat_denchu_catalog, he df_denchu_consts = jjjexperiment.denchu.denchu_1.get_DataFrame_denchu_modeling_consts( heat_denchu_catalog, R2, R1, R0, heat_real_inner, P_rac_fan_rtd_H ) - df_denchu_consts.to_csv( + write_dataframe_csv( + df_denchu_consts, artifact_paths.denchu_constants_csv_path(case_name, "H"), encoding='cp932', ) @@ -721,7 +723,8 @@ def _get_cooling_fan_model(cool_ac_setting, V_hs_dsgn_C, cool_denchu_catalog, co df_denchu_consts = jjjexperiment.denchu.denchu_1.get_DataFrame_denchu_modeling_consts( cool_denchu_catalog, R2, R1, R0, cool_real_inner, P_rac_fan_rtd_C ) - df_denchu_consts.to_csv( + write_dataframe_csv( + df_denchu_consts, artifact_paths.denchu_constants_csv_path(case_name, "C"), encoding='cp932', ) @@ -1035,7 +1038,8 @@ def _write_outputs_and_build_test_result(case_name, df_output2, climate, test_mo df_output1 = pd.DataFrame(index=['合計値']) df_output1['E_H [MJ/year]'] = E_H df_output1['E_C [MJ/year]'] = E_C - df_output1.to_csv( + write_dataframe_csv( + df_output1, artifact_paths.main_output_csv_path(case_name, 1), encoding='cp932', ) @@ -1059,7 +1063,8 @@ def _write_outputs_and_build_test_result(case_name, df_output2, climate, test_mo df_output2['q_hs_H_d_t [Wh/h]'] = q_hs_H_d_t df_output2['q_hs_CS_d_t [Wh/h]'] = q_hs_CS_d_t df_output2['q_hs_CL_d_t [Wh/h]'] = q_hs_CL_d_t - df_output2.to_csv( + write_dataframe_csv( + df_output2, artifact_paths.main_output_csv_path(case_name, 2), encoding='cp932', ) diff --git a/packages/pyhees-jjj/src/jjjexperiment/section4_2_a_jjj.py b/packages/pyhees-jjj/src/jjjexperiment/section4_2_a_jjj.py index 6fe5372..b2a69a2 100644 --- a/packages/pyhees-jjj/src/jjjexperiment/section4_2_a_jjj.py +++ b/packages/pyhees-jjj/src/jjjexperiment/section4_2_a_jjj.py @@ -9,6 +9,7 @@ from jjjexperiment.common import Array8760 from jjjexperiment.logger import LimitedLoggerAdapter as _logger, log_res # デバッグ用ロガー import jjjexperiment.artifact_paths as artifact_paths +from jjjexperiment.csv_artifacts import write_dataframe_csv from jjjexperiment.inputs.options import 計算モデル import jjjexperiment.latent_load.compressor_efficiency as jjj_latent @@ -160,7 +161,8 @@ def _write_type4_heating_output( E_E_fan_H_d_t): df_output_denchuH = _build_type4_heating_output( q_hs_H_d_t, COP_H_d_t, E_E_CRAC_H_d_t, E_E_fan_H_d_t) - df_output_denchuH.to_csv( + write_dataframe_csv( + df_output_denchuH, artifact_paths.denchu_output_csv_path(case_name, "H"), encoding='cp932') # =Shift_JIS @@ -183,7 +185,8 @@ def _write_type4_cooling_output( E_E_fan_C_d_t): df_output_denchuC = _build_type4_cooling_output( q_hs_C_d_t, COP_C_d_t, E_E_CRAC_C_d_t, E_E_fan_C_d_t) - df_output_denchuC.to_csv( + write_dataframe_csv( + df_output_denchuC, artifact_paths.denchu_output_csv_path(case_name, "C"), encoding='cp932') # =Shift_JIS diff --git a/packages/pyhees-jjj/src/jjjexperiment/section4_2_jjj.py b/packages/pyhees-jjj/src/jjjexperiment/section4_2_jjj.py index 1c1e6c9..03eb6e2 100644 --- a/packages/pyhees-jjj/src/jjjexperiment/section4_2_jjj.py +++ b/packages/pyhees-jjj/src/jjjexperiment/section4_2_jjj.py @@ -24,6 +24,7 @@ from jjjexperiment.common import Array5x8760, jjj_cloning import jjjexperiment.constants as jjj_consts import jjjexperiment.artifact_paths as artifact_paths +from jjjexperiment.csv_artifacts import write_dataframe_csv from jjjexperiment.logger import LimitedLoggerAdapter as _logger # デバッグ用ロガー from jjjexperiment.inputs.options import ( VAVありなしの吹出風量, @@ -2313,13 +2314,13 @@ def _export_standard_outputs(inputs: _StandardOutputExportInputs): case(None, None): raise Exception("q_hs_rtd_H, q_hs_rtd_C はどちらかのみを前提") case(_, None): - df_output3.to_csv(artifact_paths.seasonal_output_csv_path(case_name, "H", 3), encoding = 'cp932') - df_output2.to_csv(artifact_paths.seasonal_output_csv_path(case_name, "H", 4), encoding = 'cp932') - df_output.to_csv(artifact_paths.seasonal_output_csv_path(case_name, "H", 5), encoding = 'cp932') + write_dataframe_csv(df_output3, artifact_paths.seasonal_output_csv_path(case_name, "H", 3), encoding = 'cp932') + write_dataframe_csv(df_output2, artifact_paths.seasonal_output_csv_path(case_name, "H", 4), encoding = 'cp932') + write_dataframe_csv(df_output, artifact_paths.seasonal_output_csv_path(case_name, "H", 5), encoding = 'cp932') case(None, _): - df_output3.to_csv(artifact_paths.seasonal_output_csv_path(case_name, "C", 3), encoding = 'cp932') - df_output2.to_csv(artifact_paths.seasonal_output_csv_path(case_name, "C", 4), encoding = 'cp932') - df_output.to_csv(artifact_paths.seasonal_output_csv_path(case_name, "C", 5), encoding = 'cp932') + write_dataframe_csv(df_output3, artifact_paths.seasonal_output_csv_path(case_name, "C", 3), encoding = 'cp932') + write_dataframe_csv(df_output2, artifact_paths.seasonal_output_csv_path(case_name, "C", 4), encoding = 'cp932') + write_dataframe_csv(df_output, artifact_paths.seasonal_output_csv_path(case_name, "C", 5), encoding = 'cp932') case(_, _): raise Exception("q_hs_rtd_H, q_hs_rtd_C はどちらかのみを前提") @@ -4018,11 +4019,13 @@ def _export_carryover_diagnostics(inputs: _CarryoverDiagnosticExportInputs): case (None, None): raise Exception("q_hs_rtd_H, q_hs_rtd_C はどちらかのみを前提") case (_, None): - df_carryover_output.to_csv( + write_dataframe_csv( + df_carryover_output, artifact_paths.carryover_output_csv_path(case_name, "H"), encoding='cp932') case (None, _): - df_carryover_output.to_csv( + write_dataframe_csv( + df_carryover_output, artifact_paths.carryover_output_csv_path(case_name, "C"), encoding='cp932') case (_, _): diff --git a/packages/pyhees-jjj/src/jjjexperiment/underfloor_ac/inputs/common.py b/packages/pyhees-jjj/src/jjjexperiment/underfloor_ac/inputs/common.py index d222f93..de76d6f 100644 --- a/packages/pyhees-jjj/src/jjjexperiment/underfloor_ac/inputs/common.py +++ b/packages/pyhees-jjj/src/jjjexperiment/underfloor_ac/inputs/common.py @@ -2,6 +2,7 @@ from dataclasses import dataclass # JJJ from jjjexperiment.inputs.options import 床下空調ロジック +from jjjexperiment.csv_artifacts import write_dataframe_csv # NOTE: データクラスからどうしてもロジックを参照するときは遅延インポートする __all__ = ['UnderfloorAc', 'UfVarsDataFrame'] @@ -54,4 +55,9 @@ def update_df(self, data: dict): def export_to_csv(self, filename: str, encoding: str = 'cp932'): '''csv書き出し''' - self._df_d_t.to_csv(filename, index=False, encoding=encoding) + write_dataframe_csv( + self._df_d_t, + filename, + index=False, + encoding=encoding, + ) diff --git a/tests/test_calculation_service.py b/tests/test_calculation_service.py index 980cbef..f227f57 100644 --- a/tests/test_calculation_service.py +++ b/tests/test_calculation_service.py @@ -1,8 +1,11 @@ +import copy import os from pathlib import Path +import pandas as pd import pytest +from jjjexperiment.csv_artifacts import capture_csv_exports, write_dataframe_csv from verification_app.services import CalculationService @@ -14,11 +17,15 @@ def calculate(input_data: dict[str, object]) -> None: Path("unrelated.txt").write_text("ignore", encoding="utf-8") def build_graphs( - input_data: dict[str, object], output_dir: Path, version: str + input_data: dict[str, object], + output_dir: Path, + version: str, + csv_exports: object | None, ) -> tuple[str, ...]: assert input_data["case_name"] == "service" assert output_dir == tmp_path / "run-first" assert version == "v1" + assert csv_exports is None return ("heating", "cooling") service = CalculationService( @@ -120,7 +127,10 @@ def calculate(input_data: dict[str, object]) -> None: Path(f"{prefix}.csv").write_text("result", encoding="utf-8") def build_graphs( - input_data: dict[str, object], output_dir: Path, version: str + input_data: dict[str, object], + output_dir: Path, + version: str, + csv_exports: object | None, ) -> tuple[object, ...]: raise KeyError("missing graph column") @@ -149,7 +159,10 @@ def calculate(input_data: dict[str, object]) -> None: Path(f"{prefix}.csv").write_text("result", encoding="utf-8") def build_graphs( - input_data: dict[str, object], output_dir: Path, version: str + input_data: dict[str, object], + output_dir: Path, + version: str, + csv_exports: object | None, ) -> tuple[str, ...]: graph_calls.append(str(input_data["case_name"])) return ("heating", "cooling") @@ -179,6 +192,52 @@ def build_graphs( assert completed.graphs == ("heating", "cooling") +def test_service_defers_csv_until_explicit_export(tmp_path: Path) -> None: + def calculate(input_data: dict[str, object]) -> None: + prefix = f"{input_data['case_name']}v1" + output1 = pd.DataFrame( + {"E_H [MJ/year]": [100.0], "E_C [MJ/year]": [200.0]}, + index=["合計値"], + ) + output2 = pd.DataFrame( + { + "E_E_H_d_t [kWh/h]": [1.0, 2.0], + "E_E_C_d_t [kWh/h]": [3.0, 4.0], + "E_UT_H_d_t [MJ/h]": [10.0, 20.0], + "E_UT_C_d_t [MJ/h]": [30.0, 40.0], + "E_E_fan_H_d_t [kWh/h]": [0.2, 0.3], + "E_E_fan_C_d_t [kWh/h]": [0.5, 1.0], + } + ) + write_dataframe_csv(output1, f"{prefix}_output1.csv", encoding="cp932") + write_dataframe_csv(output2, f"{prefix}_output2.csv", encoding="cp932") + + service = CalculationService( + calculate, + lambda: "v1", + workdir=tmp_path, + run_id_factory=lambda: "deferred-csv", + csv_export_session=capture_csv_exports, + ) + + result = service.run({"case_name__0": "deferred"}, include_graphs=False) + artifact_dir = tmp_path / "run-deferred-csv" + + assert result.succeeded + assert result.annual_summary is not None + assert result.annual_summary.heating.primary_energy_mj == 100.0 + assert result.annual_summary.cooling.unprocessed_load_mj == 70.0 + assert not tuple(artifact_dir.glob("*.csv")) + assert result.csv_status.startswith("CSVファイルは未出力") + assert copy.deepcopy(result).csv_exports is result.csv_exports + + exported = service.export_csv(result) + + assert exported.csv_status == "✅ 2件のCSVファイルを出力しました。" + assert len(tuple(artifact_dir.glob("*.csv"))) == 2 + assert {Path(path).suffix for path in exported.files} == {".csv"} + + def test_same_case_name_uses_independent_artifact_directories(tmp_path: Path) -> None: call_number = 0 diff --git a/tests/test_gradio_app.py b/tests/test_gradio_app.py index 145a8fc..1127be4 100644 --- a/tests/test_gradio_app.py +++ b/tests/test_gradio_app.py @@ -114,6 +114,8 @@ def equipment_section(prefix: str) -> dict[str, object]: assert "field:U_s_vert__0" not in components_by_key assert "↩ 入力をデフォルトに戻す" in buttons assert buttons["↩ 入力をデフォルトに戻す"]["props"]["variant"] == "secondary" + assert "CSVファイルを出力" in buttons + assert buttons["CSVファイルを出力"]["props"]["interactive"] is False assert origin_classes == { "input-origin-bri-web": 47, "input-origin-verification-platform": 176, @@ -131,7 +133,7 @@ def equipment_section(prefix: str) -> dict[str, object]: assert equipment_section(prefix)["props"]["visible"] is False dependencies = config["dependencies"] - calculation_started, calculation, graph_generation = dependencies[:3] + calculation_started, calculation, graph_generation, csv_export = dependencies[:4] reset_inputs = next( dependency for dependency in dependencies if len(dependency["outputs"]) == 231 ) @@ -142,16 +144,18 @@ def equipment_section(prefix: str) -> dict[str, object]: ) visibility_dependencies = tuple( dependency - for dependency in dependencies[3:] + for dependency in dependencies[4:] if dependency not in (reset_inputs, install_default_highlights) ) assert calculation_started["queue"] is False assert len(calculation_started["inputs"]) == 0 - assert len(calculation_started["outputs"]) == 11 + assert len(calculation_started["outputs"]) == 13 assert len(calculation["inputs"]) == 223 - assert len(calculation["outputs"]) == 7 + assert len(calculation["outputs"]) == 9 assert len(graph_generation["inputs"]) == 1 assert len(graph_generation["outputs"]) == 7 + assert len(csv_export["inputs"]) == 1 + assert len(csv_export["outputs"]) == 5 assert len(visibility_dependencies) >= 14 assert 2 in {len(dependency["outputs"]) for dependency in visibility_dependencies} assert 3 in {len(dependency["outputs"]) for dependency in visibility_dependencies} diff --git a/tests/test_result_graphs.py b/tests/test_result_graphs.py index 9cd6b07..08ee8d4 100644 --- a/tests/test_result_graphs.py +++ b/tests/test_result_graphs.py @@ -2,6 +2,7 @@ import gradio as gr import pandas as pd +from jjjexperiment.csv_artifacts import capture_csv_exports, write_dataframe_csv from matplotlib.figure import Figure from verification_app.graphs import ( @@ -72,3 +73,41 @@ def test_annual_apf_is_unavailable_without_electricity() -> None: apf = _ratio_of_sums(pd.Series([1.0, 2.0]), pd.Series([0.0, 0.0])) assert _format_apf(apf) == "算定不能" + + +def test_build_result_graphs_reads_deferred_dataframes(tmp_path: Path) -> None: + index = pd.to_datetime(["2023-02-05", "2023-08-06"]) + output2 = pd.DataFrame( + { + "Theta_ex_d_t [℃]": [2.0, 30.0], + "q_hs_H_d_t [Wh/h]": [3000.0, 0.0], + "q_hs_CS_d_t [Wh/h]": [0.0, 2500.0], + "q_hs_CL_d_t [Wh/h]": [0.0, 500.0], + "E_E_H_d_t [kWh/h]": [1.0, 0.0], + "E_E_C_d_t [kWh/h]": [0.0, 1.0], + "E_E_fan_H_d_t [kWh/h]": [0.1, 0.0], + "E_E_fan_C_d_t [kWh/h]": [0.0, 0.1], + "E_UT_H_d_t [MJ/h]": [0.0, 0.0], + "E_UT_C_d_t [MJ/h]": [0.0, 0.0], + "Theta_hs_H_out_d_t [℃]": [35.0, 0.0], + "Theta_hs_H_in_d_t [℃]": [20.0, 0.0], + "Theta_hs_C_out_d_t [℃]": [0.0, 16.0], + "Theta_hs_C_in_d_t [℃]": [0.0, 27.0], + "V_hs_supply_H_d_t [m3/h]": [1000.0, 0.0], + "V_hs_supply_C_d_t [m3/h]": [0.0, 1000.0], + "E_H_d_t [MJ/h]": [10.0, 0.0], + "E_C_d_t [MJ/h]": [0.0, 9.0], + }, + index=index, + ) + output5 = pd.DataFrame({"X_ex_d_t": [0.004, 0.012]}, index=index) + prefix = tmp_path / "graphsv1" + with capture_csv_exports() as exports: + write_dataframe_csv(output2, f"{prefix}_output2.csv", encoding="cp932") + write_dataframe_csv(output5, f"{prefix}_H_output5.csv", encoding="cp932") + write_dataframe_csv(output5, f"{prefix}_C_output5.csv", encoding="cp932") + + figures = build_result_graphs({"case_name": "graphs"}, tmp_path, "v1", exports) + + assert len(figures) == 5 + assert not tuple(tmp_path.glob("*.csv"))