Skip to content

Commit f3fb296

Browse files
committed
refactor: Split up report and sidebar modules into individual components
1 parent 0e0cf63 commit f3fb296

27 files changed

Lines changed: 2898 additions & 1624 deletions

src/solarbatteryield/report.py

Lines changed: 0 additions & 811 deletions
This file was deleted.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
"""
2+
Report components for PV analysis visualization.
3+
4+
This package provides modular report rendering with separate components for:
5+
- Tables (scenario overview, incremental analysis, summary)
6+
- Charts (monthly energy balance, SoC comparison, longterm PV vs ETF)
7+
- Header and footer sections
8+
"""
9+
from solarbatteryield.report.core import Report, render_report
10+
from solarbatteryield.report.landing import render_landing_page
11+
12+
__all__ = [
13+
"Report",
14+
"render_report",
15+
"render_landing_page",
16+
]
17+
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""
2+
Altair chart configuration helpers.
3+
4+
Provides shared configuration for German locale and consistent styling
5+
across all charts in the report.
6+
"""
7+
from __future__ import annotations
8+
9+
from typing import Any
10+
11+
# German locale configuration for Altair charts
12+
GERMAN_LOCALE: dict[str, Any] = {
13+
"number": {
14+
"decimal": ",",
15+
"thousands": ".",
16+
"grouping": [3],
17+
"currency": ["", " €"],
18+
}
19+
}
20+
21+
22+
def configure_german_locale(chart):
23+
"""
24+
Apply German locale configuration to an Altair chart.
25+
26+
Args:
27+
chart: Altair chart object
28+
29+
Returns:
30+
Chart with German locale configuration applied
31+
"""
32+
return chart.configure(locale=GERMAN_LOCALE)
33+
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
"""
2+
Report context providing shared data for all report components.
3+
4+
The ReportContext encapsulates all configuration and results needed by report
5+
components, avoiding the need to pass many parameters to each component.
6+
"""
7+
from __future__ import annotations
8+
9+
from dataclasses import dataclass
10+
from typing import TYPE_CHECKING
11+
12+
import streamlit as st
13+
14+
if TYPE_CHECKING:
15+
from solarbatteryield.models import SimulationConfig, AnalysisResult, ScenarioResult
16+
17+
18+
def get_short_place_name() -> str | None:
19+
"""
20+
Get a short, readable place name from session state.
21+
22+
The location name is stored in session state by the sidebar,
23+
either from geocoding (forward lookup) or reverse geocoding.
24+
25+
Returns:
26+
Short place name like "München, Bayern" or None if not available.
27+
"""
28+
return st.session_state.get("_location_display_name")
29+
30+
31+
@dataclass
32+
class ReportContext:
33+
"""
34+
Shared context for all report components.
35+
36+
Encapsulates configuration and results, providing convenient accessors
37+
for commonly used values.
38+
"""
39+
config: SimulationConfig
40+
results: AnalysisResult
41+
42+
# ─── Convenience properties ─────────────────────────────────────────────────
43+
44+
@property
45+
def e_price(self) -> float:
46+
"""Electricity price in EUR/kWh."""
47+
return self.config.economics.e_price
48+
49+
@property
50+
def e_inc(self) -> float:
51+
"""Annual electricity price increase (decimal)."""
52+
return self.config.economics.e_inc
53+
54+
@property
55+
def etf_ret(self) -> float:
56+
"""Annual ETF return (decimal)."""
57+
return self.config.economics.etf_ret
58+
59+
@property
60+
def feed_in_tariff(self) -> float:
61+
"""Feed-in tariff in EUR/kWh."""
62+
return self.config.feed_in_tariff_eur
63+
64+
@property
65+
def analysis_years(self) -> int:
66+
"""Analysis horizon in years."""
67+
return self.config.economics.analysis_years
68+
69+
@property
70+
def reinvest_savings(self) -> bool:
71+
"""Whether to reinvest savings with ETF return."""
72+
return self.config.economics.reinvest_savings
73+
74+
@property
75+
def total_consumption(self) -> float:
76+
"""Total annual consumption in kWh."""
77+
return self.results.total_consumption
78+
79+
@property
80+
def scenarios(self) -> list[ScenarioResult]:
81+
"""List of scenario results."""
82+
return self.results.scenarios
83+
84+
@property
85+
def pv_generation_total(self) -> float:
86+
"""Total PV generation in kWh/year."""
87+
return self.results.pv_generation_total
88+
89+
@property
90+
def lat(self) -> float | None:
91+
"""Latitude of location."""
92+
return self.config.lat
93+
94+
@property
95+
def lon(self) -> float | None:
96+
"""Longitude of location."""
97+
return self.config.lon
98+
99+
@property
100+
def data_year(self) -> int:
101+
"""PVGIS data year."""
102+
return self.config.pv_system.data_year
103+
104+
@property
105+
def total_peak_kwp(self) -> float:
106+
"""Total PV peak power in kWp."""
107+
return self.config.total_peak_kwp
108+
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""
2+
Core Report class that orchestrates all report components.
3+
"""
4+
from __future__ import annotations
5+
6+
from typing import TYPE_CHECKING
7+
8+
from solarbatteryield.report.context import ReportContext
9+
from solarbatteryield.report.header import render_header
10+
from solarbatteryield.report.tables import (
11+
render_scenario_overview,
12+
render_incremental_analysis,
13+
render_scenario_details,
14+
render_summary,
15+
)
16+
from solarbatteryield.report.monthly_chart import render_monthly_energy_balance
17+
from solarbatteryield.report.soc_chart import render_weekly_soc_comparison
18+
from solarbatteryield.report.longterm_chart import render_longterm_comparison
19+
from solarbatteryield.report.footer import render_share_button
20+
21+
if TYPE_CHECKING:
22+
from solarbatteryield.models import SimulationConfig, AnalysisResult
23+
24+
25+
class Report:
26+
"""
27+
Renders the complete PV analysis report.
28+
29+
Orchestrates all report components in the correct order,
30+
using a shared ReportContext for configuration and results.
31+
"""
32+
33+
def __init__(self, config: SimulationConfig, results: AnalysisResult) -> None:
34+
"""
35+
Initialize the report with configuration and results.
36+
37+
Args:
38+
config: Simulation configuration
39+
results: Analysis results with all scenarios
40+
"""
41+
self.ctx = ReportContext(config=config, results=results)
42+
43+
def render(self) -> None:
44+
"""Render the complete analysis report."""
45+
render_header(self.ctx)
46+
render_scenario_overview(self.ctx)
47+
render_monthly_energy_balance(self.ctx)
48+
render_weekly_soc_comparison(self.ctx)
49+
render_incremental_analysis(self.ctx)
50+
render_longterm_comparison(self.ctx)
51+
render_scenario_details(self.ctx)
52+
render_summary(self.ctx)
53+
render_share_button()
54+
55+
56+
def render_report(config: SimulationConfig, results: AnalysisResult) -> None:
57+
"""
58+
Convenience function to create and render a report.
59+
60+
Args:
61+
config: Simulation configuration
62+
results: Analysis results with all scenarios
63+
"""
64+
Report(config, results).render()
65+
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""
2+
Report footer components: share button and data attribution.
3+
"""
4+
from __future__ import annotations
5+
6+
import streamlit as st
7+
8+
from solarbatteryield.state import encode_config
9+
10+
11+
def render_share_button() -> None:
12+
"""
13+
Render the configuration sharing button.
14+
15+
Creates a shareable URL with all current configuration parameters
16+
encoded as a compressed base64 string.
17+
"""
18+
st.divider()
19+
if st.button("🔗 Link mit aktueller Konfiguration erstellen"):
20+
try:
21+
encoded = encode_config()
22+
base_url = st.context.headers.get("Origin", "")
23+
share_url = f"{base_url}/?cfg={encoded}"
24+
st.code(share_url, language=None)
25+
st.caption("Link kopieren und teilen – alle Parameter sind im Link gespeichert.")
26+
except Exception as exc:
27+
st.error(f"Fehler beim Erstellen des Links: {exc}")
28+
29+
render_data_attribution()
30+
31+
32+
def render_data_attribution() -> None:
33+
"""
34+
Render data source attribution footer.
35+
36+
Credits PVGIS and OpenStreetMap as data sources.
37+
"""
38+
st.divider()
39+
st.caption(
40+
"📊 Datenquellen: PV-Daten © [PVGIS](https://re.jrc.ec.europa.eu/pvg_tools/en/) (European Commission) · "
41+
"Geocoding © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors, "
42+
"[ODbL](https://opendatacommons.org/licenses/odbl/)"
43+
)
44+
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""
2+
Report header section with key metrics.
3+
"""
4+
from __future__ import annotations
5+
6+
import streamlit as st
7+
8+
from solarbatteryield.report.context import ReportContext, get_short_place_name
9+
from solarbatteryield.utils import de
10+
11+
12+
def render_header(ctx: ReportContext) -> None:
13+
"""
14+
Render the main header section with key metrics.
15+
16+
Displays:
17+
- Title with location info
18+
- Key metrics: consumption, cost, PV power, PV generation
19+
20+
Args:
21+
ctx: Report context with config and results
22+
"""
23+
st.title("☀️ SolarBatterYield - PV-Analyse mit Speichervergleich")
24+
25+
place_name = get_short_place_name()
26+
if place_name:
27+
location_str = f"{place_name} ({ctx.lat}°N / {ctx.lon}°E)"
28+
else:
29+
location_str = f"{ctx.lat}°N / {ctx.lon}°E"
30+
31+
st.caption(f"PVGIS-Stundenwerte {ctx.data_year} · Standort {location_str}")
32+
33+
col1, col2, col3, col4 = st.columns(4)
34+
col1.metric("Gesamtverbrauch", f"{de(ctx.total_consumption)} kWh/a")
35+
col2.metric("Stromkosten ohne PV", f"{de(ctx.total_consumption * ctx.e_price)} €/a")
36+
col3.metric("PV-Leistung", f"{de(ctx.total_peak_kwp, 1)} kWp")
37+
col4.metric("PV-Erzeugung", f"{de(ctx.pv_generation_total)} kWh/a")
38+
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
"""
2+
Landing page component.
3+
"""
4+
from __future__ import annotations
5+
6+
import streamlit as st
7+
8+
from solarbatteryield.report.footer import render_data_attribution
9+
10+
11+
def render_landing_page(missing: list[str]) -> None:
12+
"""
13+
Render the application landing page.
14+
15+
Displays a welcome message, lists any missing configuration parameters,
16+
and describes the default system that will be used once configured.
17+
18+
Args:
19+
missing: List of missing configuration items with descriptions
20+
"""
21+
st.title("☀️ SolarBatterYield - PV-Analyse mit Speichervergleich")
22+
st.markdown(
23+
"Interaktive App zur Simulation und Wirtschaftlichkeitsanalyse von "
24+
"Photovoltaik-Anlagen mit Batteriespeicher – optimiert für **Balkonkraftwerke**."
25+
)
26+
missing_list = "\n".join(f"- {m}" for m in missing)
27+
st.info(
28+
"Bitte konfiguriere mindestens folgende Parameter in der **Seitenleiste** (⚙️), "
29+
f"um die Analyse zu starten:\n\n{missing_list}"
30+
)
31+
32+
st.markdown(
33+
"Nach Hinzufügen der erforderlichen Parameter wird ein Report mit Standardwerten generiert. "
34+
"Er verwendet ein beispielhaftes System, bestehend aus:"
35+
)
36+
st.markdown("- ☀️ PV-Modulen mit 2kWp Leistung in Südausrichtung")
37+
st.markdown("- 🔋️ drei unterschiedlichen Speicherkonfigurationen mit bis zu 6kWh")
38+
st.markdown(
39+
"Du kannst diese beliebig erweitern, ändern, löschen oder zusätzliche Einstellungen anpassen - "
40+
"der Report wird automatisch aktualisiert."
41+
)
42+
43+
render_data_attribution()
44+

0 commit comments

Comments
 (0)