From 56856a0677405e44545a988dfbb5c60ffe676a6c Mon Sep 17 00:00:00 2001 From: IGUCHI Masato <67450288+iguchi-lab@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:34:00 +0900 Subject: [PATCH] Improve calculation results display --- apps/gradio/src/verification_app/form_app.py | 152 ++++++++++++- apps/gradio/src/verification_app/services.py | 212 ++++++++++++++++++- tests/test_calculation_service.py | 47 +++- tests/test_gradio_app.py | 33 ++- 4 files changed, 432 insertions(+), 12 deletions(-) diff --git a/apps/gradio/src/verification_app/form_app.py b/apps/gradio/src/verification_app/form_app.py index 2f1eeb4..ffae645 100644 --- a/apps/gradio/src/verification_app/form_app.py +++ b/apps/gradio/src/verification_app/form_app.py @@ -4,6 +4,7 @@ import os import time from collections.abc import Iterable +from html import escape from itertools import islice from pathlib import Path from typing import Any @@ -15,7 +16,7 @@ from .form_model import FormField, FormModel, load_form_model from .graphs import GRAPH_LABELS -from .services import CalculationResult, CalculationService +from .services import AnnualMetrics, AnnualSummary, CalculationResult, CalculationService _UNDERFLOOR_INPUT_GUIDE_URL = ( "https://github.com/iguchi-lab/Verification-Platform-Next/" @@ -82,6 +83,68 @@ line-height: 1.45 !important; margin: 0.65rem 0 0.35rem !important; } +.annual-summary { + margin: 0.85rem 0 1rem; +} +.annual-summary__title { + font-size: 1.35rem; + font-weight: 700; + margin: 0 0 0.7rem; +} +.annual-summary__note { + color: var(--body-text-color-subdued); + font-size: 0.85rem; + line-height: 1.55; + margin: -0.35rem 0 0.8rem; +} +.annual-summary__seasons { + display: grid; + gap: 0.9rem; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} +.annual-summary__season { + background: var(--block-background-fill); + border: 1px solid var(--border-color-primary); + border-radius: 0.65rem; + padding: 1rem; +} +.annual-summary__season--heating { border-top: 5px solid #ef4444; } +.annual-summary__season--cooling { border-top: 5px solid #2563eb; } +.annual-summary__season h3 { + font-size: 1.3rem; + margin: 0 0 0.75rem; +} +.annual-summary__metrics { + display: grid; + gap: 0.65rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} +.annual-summary__metric { + background: var(--background-fill-secondary); + border-radius: 0.45rem; + padding: 0.7rem; +} +.annual-summary__label { + color: var(--body-text-color-subdued); + display: block; + font-size: 0.9rem; + font-weight: 600; +} +.annual-summary__value { + display: block; + font-size: 1.4rem; + font-weight: 750; + line-height: 1.3; + margin-top: 0.15rem; +} +.annual-summary__unit { + font-size: 0.82rem; + font-weight: 500; + white-space: nowrap; +} +@media (max-width: 700px) { + .annual-summary__metrics { grid-template-columns: 1fr; } +} """ @@ -149,7 +212,7 @@ def build_app( section_dom_id = f"input-section-{section_index}" with gr.Accordion( section.name, - open=section_index == 0, + open=False, visible=_section_is_visible(section.name, section_fields, { field.key: field.visible for field in form.fields }), @@ -195,14 +258,19 @@ def build_app( run = gr.Button("▶ 計算を実行", variant="primary", size="lg") status = gr.Markdown("**状態: 未実行**") + annual_summary = gr.HTML( + value="", + visible=False, + elem_classes=["annual-summary"], + ) result_state = gr.State() with gr.Tabs(): with gr.Tab("計算入力"): preview = gr.JSON(label="計算に使用した input_data") with gr.Tab("計算ログ"): log = gr.Textbox( - label="標準出力・エラー", - lines=16, + label="計算の内容・年間結果・計算エンジン詳細ログ", + lines=28, interactive=False, ) with gr.Tab("グラフ"): @@ -229,14 +297,30 @@ def generate_graphs(result: CalculationResult | None) -> tuple[Any, ...]: calculation_started = run.click( _calculation_started_outputs, - outputs=[status, preview, log, graph_status, *graphs, files], + outputs=[ + status, + annual_summary, + preview, + log, + graph_status, + *graphs, + files, + ], queue=False, api_visibility="private", ) calculation_finished = calculation_started.then( calculate, inputs=ordered_components, - outputs=[result_state, status, preview, log, graph_status, files], + outputs=[ + result_state, + status, + annual_summary, + preview, + log, + graph_status, + files, + ], concurrency_limit=1, concurrency_id="calculation", show_progress="full", @@ -668,6 +752,7 @@ def _calculation_started_outputs() -> tuple[Any, ...]: return ( "⏳ 計算要求を受け付けました。先行計算がある場合は、順番に実行します。" "画面を閉じずにお待ちください。", + gr.update(value="", visible=False), None, "", "計算完了後にグラフを生成します。", @@ -685,6 +770,10 @@ def _calculation_outputs(result: CalculationResult) -> tuple[Any, ...]: return ( result, result.status, + gr.update( + value=_annual_summary_html(result.annual_summary), + visible=result.succeeded and result.annual_summary is not None, + ), result.input_data, result.log, graph_status, @@ -692,6 +781,57 @@ def _calculation_outputs(result: CalculationResult) -> tuple[Any, ...]: ) +def _annual_summary_html(summary: AnnualSummary | None) -> str: + if summary is None: + return "" + + def metric(label: str, value: float, unit: str) -> str: + return ( + '
' + f'{escape(label)}' + f'{value:,.1f} ' + f'{escape(unit)}' + "
" + ) + + def season(label: str, css_name: str, values: AnnualMetrics) -> str: + return ( + f'
' + f"

{escape(label)}

" + '
' + + metric("一次エネルギー消費量", values.primary_energy_mj, "MJ/年") + + metric( + "未処理負荷(一次エネルギー相当)", + values.unprocessed_load_mj, + "MJ/年", + ) + + metric( + "消費電力量(エアコン)", + values.air_conditioner_electricity_kwh, + "kWh/年", + ) + + metric( + "消費電力量(ファン)", + values.fan_electricity_kwh, + "kWh/年", + ) + + "
" + ) + + return ( + '
' + '
年間計算結果
' + '
' + '一次エネルギー消費量には未処理負荷の一次エネルギー相当分を含みます。' + 'エアコンの消費電力量は、熱源機とファンの合計からファン分を除いた値です。' + "
" + '
' + + season("暖房", "heating", summary.heating) + + season("冷房", "cooling", summary.cooling) + + "
" + ) + + def _graph_outputs(result: CalculationResult | None) -> tuple[Any, ...]: if result is None: return ( diff --git a/apps/gradio/src/verification_app/services.py b/apps/gradio/src/verification_app/services.py index 77dd063..2cfdb91 100644 --- a/apps/gradio/src/verification_app/services.py +++ b/apps/gradio/src/verification_app/services.py @@ -1,6 +1,8 @@ from __future__ import annotations +import csv import io +import math import os import shutil import threading @@ -34,6 +36,21 @@ class CalculationResult: files: tuple[str, ...] graph_status: str graphs: tuple[Any, ...] + annual_summary: AnnualSummary | None = None + + +@dataclass(frozen=True, slots=True) +class AnnualMetrics: + primary_energy_mj: float + unprocessed_load_mj: float + air_conditioner_electricity_kwh: float + fan_electricity_kwh: float + + +@dataclass(frozen=True, slots=True) +class AnnualSummary: + heating: AnnualMetrics + cooling: AnnualMetrics class CalculationService: @@ -80,20 +97,38 @@ 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) result = CalculationResult( succeeded=True, status=f"✅ 計算が完了しました。(計算ID: {run_id[:12]})", input_data=input_data, run_id=run_id, artifact_dir=str(artifact_dir), - log=output.getvalue(), + log=_format_success_log( + input_data, + run_id, + self._version_info(), + artifact_dir, + files, + annual_summary, + output.getvalue(), + ), files=files, graph_status="計算完了後にグラフを表示します。", graphs=(), + annual_summary=annual_summary, ) return self.generate_graphs(result) if include_graphs else result except Exception: - log = output.getvalue() + traceback.format_exc() + error = traceback.format_exc() + log = _format_failure_log( + input_data, + run_id, + self._version_info(), + artifact_dir, + output.getvalue(), + error, + ) return CalculationResult( succeeded=False, status=( @@ -152,6 +187,51 @@ def _result_files( if path.is_file() ) + def _annual_summary( + self, + input_data: Mapping[str, Any], + artifact_dir: Path, + ) -> 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) + + def metrics(suffix: str) -> AnnualMetrics: + total_electricity = sum_column(f"E_E_{suffix}_d_t [kWh/h]") + fan_electricity = sum_column(f"E_E_fan_{suffix}_d_t [kWh/h]") + air_conditioner_electricity = total_electricity - fan_electricity + if abs(air_conditioner_electricity) < 1e-9: + air_conditioner_electricity = 0.0 + return AnnualMetrics( + primary_energy_mj=float(annual[f"E_{suffix} [MJ/year]"]), + unprocessed_load_mj=sum_column(f"E_UT_{suffix}_d_t [MJ/h]"), + air_conditioner_electricity_kwh=air_conditioner_electricity, + fan_electricity_kwh=fan_electricity, + ) + + return AnnualSummary(heating=metrics("H"), cooling=metrics("C")) + except (OSError, KeyError, TypeError, ValueError): + # The calculation itself succeeded. A missing optional summary must not + # discard the engine outputs or prevent users from downloading them. + return None + def _create_artifact_dir(self, output_root: Path) -> tuple[str, Path]: for _ in range(10): run_id = self._run_id_factory() @@ -192,3 +272,131 @@ def _validate_case_name(value: object) -> None: or PureWindowsPath(case_name).name != case_name ): raise ValueError("計算条件名にパスとして解釈される文字は使用できません。") + + +_MODEL_NAMES = { + 1: "ダクト式セントラル空調機", + 2: "RAC活用型全館空調(現行省エネ法RACモデル)", + 3: "RAC活用型全館空調(潜熱評価モデル)", + 4: "電中研モデル", +} + + +def _format_input_summary(input_data: Mapping[str, Any] | None) -> list[str]: + if input_data is None: + return ["入力データの構築前にエラーが発生しました。"] + heating = input_data.get("H_A") + cooling = input_data.get("C_A") + heating = heating if isinstance(heating, Mapping) else {} + cooling = cooling if isinstance(cooling, Mapping) else {} + return [ + f"計算条件名: {input_data.get('case_name', 'default')}", + f"地域区分: {input_data.get('region', '不明')}地域", + f"暖房方式: {_MODEL_NAMES.get(heating.get('type'), heating.get('type', '不明'))}", + f"冷房方式: {_MODEL_NAMES.get(cooling.get('type'), cooling.get('type', '不明'))}", + "暖房VAV / 全般換気: " + f"{_yes_no(heating.get('VAV'))} / {_yes_no(heating.get('general_ventilation'))}", + "冷房VAV / 全般換気: " + f"{_yes_no(cooling.get('VAV'))} / {_yes_no(cooling.get('general_ventilation'))}", + "床下換気 / 床下空調: " + f"{_enabled_when_two(input_data.get('underfloor_ventilation'))} / " + f"{_enabled_when_two(input_data.get('change_underfloor_temperature'))}", + f"気象データ: {_file_source(input_data.get('climateFile'))}", + f"暖冷房負荷データ: {_file_source(input_data.get('loadFile'))}", + f"入力項目数(エンジン入力): {len(input_data)}", + ] + + +def _yes_no(value: object) -> str: + return "あり" if str(value) == "1" else "なし" if str(value) == "2" else str(value) + + +def _enabled_when_two(value: object) -> str: + return "使用する" if str(value) == "2" else "使用しない" if str(value) == "1" else str(value) + + +def _file_source(value: object) -> str: + return "標準データを使用" if value in (None, "", "-") else str(value) + + +def _format_annual_summary(summary: AnnualSummary | None) -> list[str]: + if summary is None: + return ["年間集計: 対象のoutput1/output2がないため表示できません。"] + lines = [ + "注: 一次エネルギー消費量には未処理負荷の一次エネルギー相当分を含みます。", + "注: エアコンの消費電力量は、熱源機とファンの合計からファン分を除いた値です。", + ] + for label, metrics in (("暖房", summary.heating), ("冷房", summary.cooling)): + lines.extend(( + f"{label} 一次エネルギー消費量: {metrics.primary_energy_mj:,.3f} MJ/年", + f"{label} 未処理負荷(一次エネルギー相当): {metrics.unprocessed_load_mj:,.3f} MJ/年", + f"{label} 消費電力量(エアコン): {metrics.air_conditioner_electricity_kwh:,.3f} kWh/年", + f"{label} 消費電力量(ファン): {metrics.fan_electricity_kwh:,.3f} kWh/年", + )) + return lines + + +def _format_success_log( + input_data: Mapping[str, Any], + run_id: str, + version: str, + artifact_dir: Path, + files: tuple[str, ...], + annual_summary: AnnualSummary | None, + engine_log: str, +) -> str: + lines = [ + "===== 1. 計算条件 =====", + *_format_input_summary(input_data), + f"計算ID: {run_id[:12]}", + f"成果物版: {version}", + "", + "===== 2. 実行した処理 =====", + "1) 画面入力を検証し、計算エンジン用input_dataへ変換", + "2) 暖房・冷房の8760時間計算を順番に実行", + "3) 一次エネルギー、未処理負荷、機器・ファン電力を集計", + "4) CSV、入力JSON、計算条件マニフェストを計算ID別に保存", + "", + "===== 3. 年間結果 =====", + *_format_annual_summary(annual_summary), + "", + "===== 4. 出力 =====", + f"保存先: {artifact_dir}", + f"出力ファイル数: {len(files)}", + *(f"- {Path(path).name}" for path in files), + "", + "===== 5. 計算エンジン詳細ログ =====", + "以下は式・分岐・機器能力などを確認するための詳細ログです。", + engine_log.rstrip() or "(詳細ログの出力はありません)", + "", + "===== 計算完了 =====", + "すべての計算と成果物保存が正常に終了しました。", + ] + return "\n".join(lines) + "\n" + + +def _format_failure_log( + input_data: Mapping[str, Any] | None, + run_id: str | None, + version: str, + artifact_dir: Path | None, + engine_log: str, + error: str, +) -> str: + lines = [ + "===== 1. 計算条件 =====", + *_format_input_summary(input_data), + f"計算ID: {run_id[:12] if run_id else '未採番'}", + f"成果物版: {version}", + "", + "===== 2. エラー発生位置 =====", + f"作業フォルダー: {artifact_dir if artifact_dir else '作成前'}", + "入力変換、8760時間計算、または成果物保存の途中で停止しました。", + "", + "===== 3. 停止前の計算エンジンログ =====", + engine_log.rstrip() or "(停止前の詳細ログはありません)", + "", + "===== 4. エラー詳細 =====", + error.rstrip(), + ] + return "\n".join(lines) + "\n" diff --git a/tests/test_calculation_service.py b/tests/test_calculation_service.py index 3ce81cc..980cbef 100644 --- a/tests/test_calculation_service.py +++ b/tests/test_calculation_service.py @@ -37,13 +37,57 @@ def build_graphs( assert result.artifact_dir == str(tmp_path / "run-first") assert result.input_data is not None assert result.input_data["case_name"] == "service" - assert result.log == "engine log\n" + assert "===== 1. 計算条件 =====" in result.log + assert "暖房方式:" in result.log + assert "===== 2. 実行した処理 =====" in result.log + assert "===== 5. 計算エンジン詳細ログ =====" in result.log + assert "engine log" in result.log assert {Path(path).suffix for path in result.files} == {".csv"} assert result.graph_status == "✅ 2件のグラフを生成しました。" assert result.graphs == ("heating", "cooling") assert (tmp_path / "run-first" / "servicev1.csv").is_file() +def test_service_builds_annual_heating_and_cooling_summary(tmp_path: Path) -> None: + def calculate(input_data: dict[str, object]) -> None: + prefix = f"{input_data['case_name']}v1" + Path(f"{prefix}_output1.csv").write_text( + ",E_H [MJ/year],E_C [MJ/year]\n合計値,100,200\n", + encoding="cp932", + ) + Path(f"{prefix}_output2.csv").write_text( + ",E_E_H_d_t [kWh/h],E_E_C_d_t [kWh/h]," + "E_UT_H_d_t [MJ/h],E_UT_C_d_t [MJ/h]," + "E_E_fan_H_d_t [kWh/h],E_E_fan_C_d_t [kWh/h]\n" + "2023-01-01 00:00:00,1,3,10,30,0.2,0.5\n" + "2023-01-01 01:00:00,2,4,20,40,0.3,1.0\n", + encoding="cp932", + ) + + service = CalculationService( + calculate, + lambda: "v1", + workdir=tmp_path, + run_id_factory=lambda: "summary", + ) + + result = service.run({"case_name__0": "annual"}, include_graphs=False) + + assert result.succeeded + assert result.annual_summary is not None + assert result.annual_summary.heating.primary_energy_mj == 100 + assert result.annual_summary.heating.unprocessed_load_mj == 30 + assert result.annual_summary.heating.air_conditioner_electricity_kwh == 2.5 + assert result.annual_summary.heating.fan_electricity_kwh == 0.5 + assert result.annual_summary.cooling.primary_energy_mj == 200 + assert result.annual_summary.cooling.unprocessed_load_mj == 70 + assert result.annual_summary.cooling.air_conditioner_electricity_kwh == 5.5 + assert result.annual_summary.cooling.fan_electricity_kwh == 1.5 + assert "暖房 一次エネルギー消費量: 100.000 MJ/年" in result.log + assert "冷房 消費電力量(ファン): 1.500 kWh/年" in result.log + assert "一次エネルギー消費量には未処理負荷" in result.log + + def test_service_returns_traceback_on_error(tmp_path: Path) -> None: def calculate(input_data: dict[str, object]) -> None: print("before failure") @@ -64,6 +108,7 @@ def calculate(input_data: dict[str, object]) -> None: assert result.artifact_dir == str(tmp_path / "run-failed") assert "before failure" in result.log assert "RuntimeError: engine failed" in result.log + assert "===== 4. エラー詳細 =====" in result.log assert result.files == () assert result.graph_status == "計算完了後にグラフを表示します。" assert result.graphs == () diff --git a/tests/test_gradio_app.py b/tests/test_gradio_app.py index ff6b7cb..313ccda 100644 --- a/tests/test_gradio_app.py +++ b/tests/test_gradio_app.py @@ -7,7 +7,11 @@ from verification_app import form_app # noqa: E402 from verification_app.form_app import build_app # noqa: E402 -from verification_app.services import CalculationService # noqa: E402 +from verification_app.services import ( # noqa: E402 + AnnualMetrics, + AnnualSummary, + CalculationService, +) def test_gradio_app_builds_all_schema_inputs_and_events() -> None: @@ -69,6 +73,7 @@ def equipment_section(prefix: str) -> dict[str, object]: assert component_types["checkbox"] == 10 assert component_types["textbox"] == 4 # three text inputs and the log output assert component_types["plot"] == 5 + assert component_types["html"] == 2 assert component_types["gallery"] == 0 assert any( "床下関連設定の入力ガイド" in value @@ -121,6 +126,7 @@ def equipment_section(prefix: str) -> dict[str, object]: "input-section" in component["props"].get("elem_classes", ()) for component in accordions.values() ) + assert all(component["props"]["open"] is False for component in accordions.values()) for prefix in ("⑦-2", "⑦-3", "⑦-4", "⑧-2", "⑧-3", "⑧-4"): assert equipment_section(prefix)["props"]["visible"] is False @@ -141,9 +147,9 @@ def equipment_section(prefix: str) -> dict[str, object]: ) assert calculation_started["queue"] is False assert len(calculation_started["inputs"]) == 0 - assert len(calculation_started["outputs"]) == 10 + assert len(calculation_started["outputs"]) == 11 assert len(calculation["inputs"]) == 223 - assert len(calculation["outputs"]) == 6 + assert len(calculation["outputs"]) == 7 assert len(graph_generation["inputs"]) == 1 assert len(graph_generation["outputs"]) == 7 assert len(visibility_dependencies) >= 14 @@ -197,6 +203,27 @@ def equipment_section(prefix: str) -> dict[str, object]: assert "先行計算がある場合は、順番に実行します" in ( form_app._calculation_started_outputs()[0] ) + assert form_app._calculation_started_outputs()[1]["visible"] is False + + +def test_annual_summary_html_shows_heating_and_cooling_metrics() -> None: + summary = AnnualSummary( + heating=AnnualMetrics(100.0, 2.0, 30.0, 4.0), + cooling=AnnualMetrics(200.0, 5.0, 60.0, 7.0), + ) + + html = form_app._annual_summary_html(summary) + + assert "年間計算結果" in html + assert "一次エネルギー消費量には未処理負荷" in html + assert "暖房" in html + assert "冷房" in html + assert "一次エネルギー消費量" in html + assert "未処理負荷(一次エネルギー相当)" in html + assert "消費電力量(エアコン)" in html + assert "消費電力量(ファン)" in html + assert "100.0" in html + assert "200.0" in html def test_highlight_javascript_uses_schema_defaults_and_field_dom_ids() -> None: