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: