frequenz-cs-reporting 0.4.4__tar.gz → 0.4.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/PKG-INFO +1 -1
  2. frequenz_cs_reporting-0.4.6/RELEASE_NOTES.md +18 -0
  3. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/app_pages/reporting.py +21 -1
  4. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/components/tables.py +70 -26
  5. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/services/data_service.py +75 -0
  6. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/styles/kpi.css +56 -0
  7. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/views/dashboard.py +34 -9
  8. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/views/metric_renderers.py +92 -8
  9. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/views/plot_renderers.py +99 -31
  10. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/views/table_renderers.py +0 -72
  11. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz_cs_reporting.egg-info/PKG-INFO +1 -1
  12. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz_cs_reporting.egg-info/scm_file_list.json +68 -68
  13. frequenz_cs_reporting-0.4.6/src/frequenz_cs_reporting.egg-info/scm_version.json +8 -0
  14. frequenz_cs_reporting-0.4.4/RELEASE_NOTES.md +0 -21
  15. frequenz_cs_reporting-0.4.4/src/frequenz_cs_reporting.egg-info/scm_version.json +0 -8
  16. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/.streamlit/config.toml +0 -0
  17. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/Dockerfile +0 -0
  18. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/LICENSE +0 -0
  19. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/MANIFEST.in +0 -0
  20. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/README.md +0 -0
  21. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/app.py +0 -0
  22. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/pyproject.toml +0 -0
  23. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/setup.cfg +0 -0
  24. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/__init__.py +0 -0
  25. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/app_pages/__init__.py +0 -0
  26. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/app_pages/home.py +0 -0
  27. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/app_pages/solar.py +0 -0
  28. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/assets/__init__.py +0 -0
  29. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/assets/neustrom_background.png +0 -0
  30. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/assets/neustrom_logo.png +0 -0
  31. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/components/__init__.py +0 -0
  32. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/components/inputs.py +0 -0
  33. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/components/plot_charts.py +0 -0
  34. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/components/sidebar_inputs.py +0 -0
  35. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/components/ui.py +0 -0
  36. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/conftest.py +0 -0
  37. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/constants.py +0 -0
  38. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/py.typed +0 -0
  39. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/rep_cs_core/__init__.py +0 -0
  40. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/rep_cs_core/config.py +0 -0
  41. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/rep_cs_core/page_spec.py +0 -0
  42. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/services/__init__.py +0 -0
  43. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/services/client_factory.py +0 -0
  44. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/services/solar_workflow.py +0 -0
  45. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/styles/dashboard.css +0 -0
  46. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/styles/home.css +0 -0
  47. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/styles/plot_card.css +0 -0
  48. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/templates/dashboard_section_divider.html +0 -0
  49. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/templates/home_feature_grid.html +0 -0
  50. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/templates/home_feature_item.html +0 -0
  51. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/templates/home_footer.html +0 -0
  52. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/templates/home_hero.html +0 -0
  53. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/templates/home_logo.html +0 -0
  54. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/templates/home_stat_item.html +0 -0
  55. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/templates/home_stats.html +0 -0
  56. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/ui_resources.py +0 -0
  57. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/utils/__init__.py +0 -0
  58. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/utils/env.py +0 -0
  59. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/utils/time.py +0 -0
  60. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/views/__init__.py +0 -0
  61. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/views/sections.py +0 -0
  62. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz/cs_reporting/views/solar_results.py +0 -0
  63. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz_cs_reporting.egg-info/SOURCES.txt +0 -0
  64. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz_cs_reporting.egg-info/dependency_links.txt +0 -0
  65. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz_cs_reporting.egg-info/requires.txt +0 -0
  66. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/src/frequenz_cs_reporting.egg-info/top_level.txt +0 -0
  67. {frequenz_cs_reporting-0.4.4 → frequenz_cs_reporting-0.4.6}/toml_directory/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frequenz-cs-reporting
3
- Version: 0.4.4
3
+ Version: 0.4.6
4
4
  Summary: Streamlit Library for reporting and solar monitoring
5
5
  Author-email: Frequenz Energy-as-a-Service GmbH <floss@frequenz.com>
6
6
  License-Expression: MIT
@@ -0,0 +1,18 @@
1
+ # Frequenz CS Reporting Library Release Notes
2
+
3
+ ## Summary
4
+
5
+
6
+ ## Upgrading
7
+
8
+
9
+ ## New Features
10
+
11
+ - Add forecasted grid import cost, grid feed-in revenue, and average day-ahead
12
+ price KPI cards for MID231.
13
+ - Add a separate battery KPI card section with battery flow metrics.
14
+
15
+ ## Bug Fixes
16
+
17
+ - Add Battery SOC plot tab only for the applicable cases.
18
+ - Use PSC stack mode for the overview time-series plot.
@@ -22,7 +22,10 @@ from frequenz.cs_reporting.services.client_factory import (
22
22
  get_component_types,
23
23
  get_microgrid_config,
24
24
  )
25
- from frequenz.cs_reporting.services.data_service import get_microgrid_data
25
+ from frequenz.cs_reporting.services.data_service import (
26
+ get_microgrid_data,
27
+ get_microgrid_soc_data,
28
+ )
26
29
  from frequenz.cs_reporting.views.dashboard import build_master_df, render_dashboard
27
30
 
28
31
 
@@ -206,6 +209,21 @@ def render() -> None:
206
209
  end_date=end_time,
207
210
  resolution=resolution,
208
211
  )
212
+ battery_soc_df = None
213
+ if "battery" in component_types:
214
+ try:
215
+ fetched_soc_df = get_microgrid_soc_data(
216
+ microgrid_id=microgrid_id,
217
+ start_date=start_time,
218
+ end_date=end_time,
219
+ resolution=resolution,
220
+ )
221
+ except Exception as exc: # pylint: disable=broad-except
222
+ st.warning(
223
+ f"Batterie-SOC-Daten konnten nicht geladen werden: {exc}"
224
+ )
225
+ else:
226
+ battery_soc_df = None if fetched_soc_df.empty else fetched_soc_df
209
227
  except Exception as exc: # pylint: disable=broad-except
210
228
  st.error(_format_data_loading_error(exc, microgrid_id))
211
229
  st.stop()
@@ -226,12 +244,14 @@ def render() -> None:
226
244
  mcfg,
227
245
  mapper,
228
246
  timezone=timezone,
247
+ battery_soc_df=battery_soc_df,
229
248
  )
230
249
  render_dashboard(
231
250
  master_df,
232
251
  resolution=resolution,
233
252
  component_types=component_types,
234
253
  mapper=mapper,
254
+ microgrid_id=microgrid_id,
235
255
  )
236
256
  _scroll_to_section_if_requested()
237
257
 
@@ -19,6 +19,7 @@ _GRID_HEADER_HEIGHT = 40
19
19
  _GRID_ROW_HEIGHT = 44
20
20
  _GRID_PAGINATION_HEIGHT = 56
21
21
  _GRID_BORDER_HEIGHT = 2
22
+ _GRID_TOOLBAR_HEIGHT = 40
22
23
 
23
24
 
24
25
  # pylint: disable=too-many-arguments
@@ -90,39 +91,82 @@ def aggrid_table(
90
91
  suppressAutoSize=True,
91
92
  )
92
93
 
93
- # Fit columns on first load
94
94
  grid_options = gb.build()
95
95
  grid_options["onGridReady"] = JsCode(f"""
96
96
  function(params) {{
97
- const gridKey = {json.dumps(key_prefix)};
98
- const messageType = "cs-reporting-reset-aggrid-filters";
99
- window.__csReportingAgGridResetHandlers =
100
- window.__csReportingAgGridResetHandlers || {{}};
101
-
102
- const previousHandler =
103
- window.__csReportingAgGridResetHandlers[gridKey];
104
- if (previousHandler) {{
105
- window.removeEventListener("message", previousHandler);
97
+ const buttonId = {json.dumps(f"{key_prefix}_reset_table_filters")};
98
+ const toolbarHeight = {_GRID_TOOLBAR_HEIGHT};
99
+ const existingButton = document.getElementById(buttonId);
100
+ if (existingButton) {{
101
+ return;
106
102
  }}
107
103
 
108
- const resetFilters = function() {{
104
+ const gridRoot = document.querySelector(".ag-root-wrapper");
105
+ const gridParent = gridRoot ? gridRoot.parentElement : null;
106
+ if (!gridRoot || !gridParent) {{
107
+ return;
108
+ }}
109
+
110
+ const currentGridHeight = gridRoot.getBoundingClientRect().height;
111
+ if (currentGridHeight > toolbarHeight) {{
112
+ gridRoot.style.height = `${{currentGridHeight - toolbarHeight}}px`;
113
+ }}
114
+
115
+ const toolbar = document.createElement("div");
116
+ Object.assign(toolbar.style, {{
117
+ alignItems: "center",
118
+ backgroundColor: "transparent",
119
+ boxSizing: "border-box",
120
+ display: "flex",
121
+ height: `${{toolbarHeight}}px`,
122
+ justifyContent: "flex-start",
123
+ padding: "4px 0 6px 0",
124
+ }});
125
+
126
+ const button = document.createElement("button");
127
+ button.id = buttonId;
128
+ button.type = "button";
129
+ button.textContent = "Filter zurücksetzen";
130
+ button.title = "Filter der Tabelle zurücksetzen";
131
+ button.setAttribute("aria-label", "Filter der Tabelle zurücksetzen");
132
+
133
+ Object.assign(button.style, {{
134
+ backgroundColor: "#fff4bf",
135
+ border: "1px solid #e4c34a",
136
+ color: "#4f3f00",
137
+ borderRadius: "6px",
138
+ cursor: "pointer",
139
+ fontFamily: "inherit",
140
+ fontSize: "12px",
141
+ fontWeight: "600",
142
+ lineHeight: "1.1",
143
+ minHeight: "26px",
144
+ padding: "4px 8px",
145
+ whiteSpace: "nowrap",
146
+ }});
147
+
148
+ button.addEventListener("mouseenter", function() {{
149
+ button.style.backgroundColor = "#ffe88a";
150
+ button.style.borderColor = "#c7a629";
151
+ button.style.color = "#3f3200";
152
+ }});
153
+ button.addEventListener("mouseleave", function() {{
154
+ button.style.backgroundColor = "#fff4bf";
155
+ button.style.borderColor = "#e4c34a";
156
+ button.style.color = "#4f3f00";
157
+ }});
158
+
159
+ button.addEventListener("click", function(event) {{
160
+ event.preventDefault();
161
+ event.stopPropagation();
162
+
109
163
  params.api.setFilterModel(null);
110
164
  params.api.onFilterChanged();
111
165
  params.api.paginationGoToFirstPage();
112
- }};
113
-
114
- const handler = function(event) {{
115
- if (
116
- event.data &&
117
- event.data.type === messageType &&
118
- event.data.gridKey === gridKey
119
- ) {{
120
- resetFilters();
121
- }}
122
- }};
123
-
124
- window.__csReportingAgGridResetHandlers[gridKey] = handler;
125
- window.addEventListener("message", handler);
166
+ }});
167
+
168
+ toolbar.appendChild(button);
169
+ gridParent.insertBefore(toolbar, gridRoot);
126
170
  }}
127
171
  """)
128
172
 
@@ -160,7 +204,7 @@ def aggrid_table(
160
204
  _ = AgGrid(
161
205
  df,
162
206
  gridOptions=grid_options,
163
- height=height,
207
+ height=height + _GRID_TOOLBAR_HEIGHT,
164
208
  theme=theme,
165
209
  allow_unsafe_jscode=True,
166
210
  update_mode=GridUpdateMode.NO_UPDATE,
@@ -99,6 +99,43 @@ async def fetch_microgrid_data(
99
99
  return df.copy()
100
100
 
101
101
 
102
+ async def fetch_microgrid_soc_data(
103
+ microgrid_id: int,
104
+ start_date: datetime,
105
+ end_date: datetime,
106
+ resolution: timedelta,
107
+ timeout: float = 30.0,
108
+ ) -> pd.DataFrame:
109
+ """Fetch battery SOC for a microgrid in ``[start_date, end_date)``.
110
+
111
+ Args:
112
+ microgrid_id: Identifier for the target microgrid.
113
+ start_date: Inclusive start date of the query range.
114
+ end_date: Exclusive end date of the query range.
115
+ resolution: Resampling period for the returned data.
116
+ timeout: Request timeout in seconds.
117
+
118
+ Returns:
119
+ Dataframe of battery SOC values. Empty when no SOC data is available.
120
+ """
121
+ start_iso, end_iso = validate_range(start_date, end_date)
122
+ client = get_microgrid_client(microgrid_id)
123
+
124
+ coro = client.soc(
125
+ microgrid_id=microgrid_id,
126
+ start=start_iso,
127
+ end=end_iso,
128
+ resampling_period=resolution,
129
+ keep_components=False,
130
+ )
131
+ with _quiet_expected_component_data_warnings():
132
+ df = await asyncio.wait_for(coro, timeout)
133
+
134
+ if df is None or (hasattr(df, "empty") and df.empty):
135
+ return pd.DataFrame()
136
+ return df.copy()
137
+
138
+
102
139
  # Cached sync wrapper for Streamlit pages
103
140
  @st.cache_data(ttl=300, show_spinner=False)
104
141
  def get_microgrid_data(
@@ -139,3 +176,41 @@ def get_microgrid_data(
139
176
  "get_microgrid_data() called from within an active event loop. "
140
177
  "Use `await fetch_microgrid_data(...)` in async contexts."
141
178
  )
179
+
180
+
181
+ @st.cache_data(ttl=300, show_spinner=False)
182
+ def get_microgrid_soc_data(
183
+ microgrid_id: int,
184
+ start_date: datetime,
185
+ end_date: datetime,
186
+ resolution: timedelta,
187
+ timeout: float = 30.0,
188
+ ) -> pd.DataFrame:
189
+ """Sync wrapper for battery SOC data with caching.
190
+
191
+ Args:
192
+ microgrid_id: Identifier for the target microgrid.
193
+ start_date: Inclusive start date of the query range.
194
+ end_date: Exclusive end date of the query range.
195
+ resolution: Resampling period for the returned data.
196
+ timeout: Request timeout in seconds.
197
+
198
+ Returns:
199
+ Dataframe of battery SOC values. Empty when no SOC data is available.
200
+
201
+ Raises:
202
+ RuntimeError: If invoked from within an active event loop instead of
203
+ using the async ``fetch_microgrid_soc_data``.
204
+ """
205
+ try:
206
+ asyncio.get_running_loop()
207
+ except RuntimeError:
208
+ return asyncio.run(
209
+ fetch_microgrid_soc_data(
210
+ microgrid_id, start_date, end_date, resolution, timeout=timeout
211
+ )
212
+ )
213
+ raise RuntimeError(
214
+ "get_microgrid_soc_data() called from within an active event loop. "
215
+ "Use `await fetch_microgrid_soc_data(...)` in async contexts."
216
+ )
@@ -24,6 +24,62 @@
24
24
  color: #4a5568;
25
25
  margin: 0;
26
26
  }
27
+ .kpi-section-warning {
28
+ position: relative;
29
+ display: inline-flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ width: 24px;
33
+ height: 24px;
34
+ margin-left: 2px;
35
+ border: 1px solid #facc15;
36
+ border-radius: 6px;
37
+ background: #fef9c3;
38
+ color: #854d0e;
39
+ cursor: help;
40
+ flex: 0 0 auto;
41
+ }
42
+ .kpi-section-warning:hover,
43
+ .kpi-section-warning:focus {
44
+ background: #fde68a;
45
+ outline: 2px solid rgba(250, 204, 21, 0.35);
46
+ outline-offset: 1px;
47
+ }
48
+ .kpi-section-warning__icon {
49
+ color: #ca8a04;
50
+ font-size: 0.82rem;
51
+ line-height: 1;
52
+ }
53
+ .kpi-section-warning__tooltip {
54
+ position: absolute;
55
+ top: calc(100% + 8px);
56
+ left: 0;
57
+ z-index: 10;
58
+ width: max-content;
59
+ max-width: 240px;
60
+ padding: 8px 10px;
61
+ border: 1px solid #facc15;
62
+ border-radius: 6px;
63
+ background: #422006;
64
+ box-shadow: 0 8px 22px rgba(15,41,74,0.18);
65
+ color: #fef9c3;
66
+ font-size: 0.72rem;
67
+ font-weight: 600;
68
+ line-height: 1.3;
69
+ text-align: left;
70
+ text-transform: none;
71
+ letter-spacing: 0;
72
+ white-space: normal;
73
+ opacity: 0;
74
+ pointer-events: none;
75
+ transform: translateY(-3px);
76
+ transition: opacity 0.15s ease, transform 0.15s ease;
77
+ }
78
+ .kpi-section-warning:hover .kpi-section-warning__tooltip,
79
+ .kpi-section-warning:focus .kpi-section-warning__tooltip {
80
+ opacity: 1;
81
+ transform: translateY(0);
82
+ }
27
83
 
28
84
  /* ── Individual KPI card ── */
29
85
  .kpi-card {
@@ -56,6 +56,25 @@ def _section_divider(label: str = "", badge: str = "") -> None:
56
56
  )
57
57
 
58
58
 
59
+ def _numeric_metric(metrics: dict[str, float | str | None], key: str) -> float | None:
60
+ """Return a metric value as a float when it is numeric."""
61
+ value = metrics.get(key)
62
+ if isinstance(value, (int, float)):
63
+ return float(value)
64
+ return None
65
+
66
+
67
+ def _average_price_ct_per_kwh(
68
+ metrics: dict[str, float | str | None], value_key: str, energy_key: str
69
+ ) -> float | None:
70
+ """Calculate an average price in ct/kWh from euro and kWh metric values."""
71
+ value = _numeric_metric(metrics, value_key)
72
+ energy = _numeric_metric(metrics, energy_key)
73
+ if value is None or energy is None or energy == 0.0:
74
+ return None
75
+ return (value * 100) / energy
76
+
77
+
59
78
  @st.cache_data(show_spinner="Preparing analysis tables…")
60
79
  def _build_tables(
61
80
  master_df: pd.DataFrame,
@@ -91,7 +110,13 @@ def _build_tables(
91
110
  is automatically invalidated when any input parameters change.
92
111
  """
93
112
  power_table = compute_energy_summary(master_df, resolution)
94
- metrics = aggregate_metrics(master_df, resolution)
113
+ metrics = aggregate_metrics(master_df, resolution, price_column="day_ahead_price")
114
+ metrics["average_da_price_grid_import"] = _average_price_ct_per_kwh(
115
+ metrics, "grid_import_cost_sum", "grid_consumption_sum"
116
+ )
117
+ metrics["average_da_price_grid_feed_in"] = _average_price_ct_per_kwh(
118
+ metrics, "grid_feed_in_revenue_sum", "grid_feed_in_sum"
119
+ )
95
120
 
96
121
  # Build component analyses using configuration
97
122
  analyses = {
@@ -105,12 +130,6 @@ def _build_tables(
105
130
  }
106
131
 
107
132
  overview_df = build_overview_df(master_df, component_types)
108
- if "day_ahead_price" in master_df.columns and "timestamp" in overview_df.columns:
109
- overview_df = overview_df.merge(
110
- master_df[["timestamp", "day_ahead_price"]],
111
- on="timestamp",
112
- how="left",
113
- )
114
133
 
115
134
  return {
116
135
  "power_table": power_table,
@@ -124,12 +143,14 @@ def _build_tables(
124
143
  }
125
144
 
126
145
 
146
+ # pylint: disable=too-many-arguments, too-many-positional-arguments
127
147
  def build_master_df(
128
148
  raw_df: pd.DataFrame,
129
149
  component_types: Iterable[str],
130
150
  mcfg: Any,
131
151
  mapper: ColumnMapper,
132
152
  timezone: str = "Europe/Berlin",
153
+ battery_soc_df: pd.DataFrame | None = None,
133
154
  ) -> pd.DataFrame:
134
155
  """Transform raw microgrid data into master analysis dataframe.
135
156
 
@@ -146,6 +167,7 @@ def build_master_df(
146
167
  site-specific settings.
147
168
  mapper: Column name mapper for converting internal names to display names.
148
169
  timezone: Timezone name to use for the report timestamps.
170
+ battery_soc_df: Optional battery SOC dataframe to merge into the report.
149
171
 
150
172
  Returns:
151
173
  Master dataframe with processed and aggregated component data, ready for
@@ -171,6 +193,7 @@ def build_master_df(
171
193
  tz_name=timezone,
172
194
  assume_tz="UTC",
173
195
  component_display_names=component_display_names,
196
+ battery_soc_df=battery_soc_df,
174
197
  )
175
198
  try:
176
199
  master_df = merge_day_ahead_prices(
@@ -187,6 +210,7 @@ def render_dashboard(
187
210
  resolution: timedelta,
188
211
  component_types: Iterable[str],
189
212
  mapper: ColumnMapper,
213
+ microgrid_id: int,
190
214
  ) -> None:
191
215
  """Render the complete microgrid reporting dashboard.
192
216
 
@@ -202,6 +226,7 @@ def render_dashboard(
202
226
  component_types: List of component types present in the microgrid for
203
227
  dynamic tab generation.
204
228
  mapper: Column name mapper for display name standardization.
229
+ microgrid_id: Identifier of the selected microgrid.
205
230
 
206
231
  Returns:
207
232
  Renders Streamlit components directly to the app interface.
@@ -214,11 +239,11 @@ def render_dashboard(
214
239
 
215
240
  # --- Overview section---
216
241
  _section_divider("Übersicht", "KPIs")
217
- sections.render_summary_boxes(tables["metrics"], component_types)
242
+ sections.render_summary_boxes(tables["metrics"], component_types, microgrid_id)
218
243
 
219
244
  # --- Plots section---
220
245
  _section_divider("Diagramme & Zeitreihen")
221
- sections.render_plots_tabs(tables, mapper)
246
+ sections.render_plots_tabs(tables, mapper, component_types)
222
247
 
223
248
  # --- Tables section---
224
249
  st.markdown('<div id="data-export-section"></div>', unsafe_allow_html=True)
@@ -26,6 +26,7 @@ _SECTION_ACCENTS: dict[str, str] = {
26
26
  "Netzkennzahlen": "#3b82f6", # blue – grid
27
27
  "(Eigen-)Erzeugungskennzahlen": "#10b981", # green – generation
28
28
  "Verbrauchskennzahlen": "#f59e0b", # amber – consumption
29
+ "Batteriekennzahlen": "#14b8a6", # teal – battery
29
30
  "Bilanzkennzahlen": "#8b5cf6", # purple – balance ratios
30
31
  }
31
32
 
@@ -33,6 +34,7 @@ _SECTION_ICONS: dict[str, str] = {
33
34
  "Netzkennzahlen": "⚡",
34
35
  "(Eigen-)Erzeugungskennzahlen": "☀",
35
36
  "Verbrauchskennzahlen": "📊",
37
+ "Batteriekennzahlen": "🔋",
36
38
  "Bilanzkennzahlen": "⚖",
37
39
  }
38
40
 
@@ -47,13 +49,44 @@ def _peak_label(metrics: dict[str, object]) -> str:
47
49
  return f"Lastspitze (kW) — {metrics.get('peak_date')}"
48
50
 
49
51
 
52
+ _INVOICING_WARNING = (
53
+ "Die dargestellten Preise und Daten basieren auf einer Prognose eigener "
54
+ "gemessener Werte und entsprechen nicht den offiziellen Daten des "
55
+ "Verteilernetzbetreibers. Für Abrechnungen sind ausschließlich die "
56
+ "Netzbetreiber-Daten maßgeblich, daher sind Abweichungen zu Rechnungswerten "
57
+ "möglich."
58
+ )
59
+
60
+
50
61
  SECTION_SPECS: list[dict[str, Any]] = [
51
62
  {
52
63
  "title": "Netzkennzahlen",
64
+ "warning": _INVOICING_WARNING,
53
65
  "boxes": [
54
66
  {"label": "Netzbezug (kWh)", "key": "grid_consumption_sum"},
55
67
  {"label": "Netzeinspeisung (kWh)", "key": "grid_feed_in_sum"},
56
68
  {"label_fn": _peak_label, "key": "peak"},
69
+ {
70
+ "label": "Prognostizierte Netzbezugskosten (€)",
71
+ "key": "grid_import_cost_sum",
72
+ "microgrid_ids": {231},
73
+ },
74
+ {
75
+ "label": "Prognostizierte Einspeiseerloese (€)",
76
+ "key": "grid_feed_in_revenue_sum",
77
+ "microgrid_ids": {231},
78
+ },
79
+ {"label": "", "key": None, "microgrid_ids": {231}},
80
+ {
81
+ "label": "Prognostizierter Preis (ct/kWh)",
82
+ "key": "average_da_price_grid_import",
83
+ "microgrid_ids": {231},
84
+ },
85
+ {
86
+ "label": "Prognostizierter Preis (ct/kWh)",
87
+ "key": "average_da_price_grid_feed_in",
88
+ "microgrid_ids": {231},
89
+ },
57
90
  ],
58
91
  },
59
92
  {
@@ -85,6 +118,33 @@ SECTION_SPECS: list[dict[str, Any]] = [
85
118
  {"label": "Eigenverbrauch (kWh)", "key": "prod_self_consumption_sum"},
86
119
  ],
87
120
  },
121
+ {
122
+ "title": "Batteriekennzahlen",
123
+ "per_row": 3,
124
+ "boxes": [
125
+ {
126
+ "label": "Erzeugung zu Batterie (kWh)",
127
+ "key": "production_to_battery_sum",
128
+ "component_type": "battery",
129
+ },
130
+ {
131
+ "label": "Netz zu Batterie (kWh)",
132
+ "key": "grid_to_battery_sum",
133
+ "component_type": "battery",
134
+ },
135
+ {"label": "", "key": None},
136
+ {
137
+ "label": "Batterie zu Netz (kWh)",
138
+ "key": "battery_to_grid_sum",
139
+ "component_type": "battery",
140
+ },
141
+ {
142
+ "label": "Batterie zu Verbrauch (kWh)",
143
+ "key": "battery_to_consumption_sum",
144
+ "component_type": "battery",
145
+ },
146
+ ],
147
+ },
88
148
  {
89
149
  "title": "Bilanzkennzahlen",
90
150
  "per_row": 3,
@@ -197,9 +257,11 @@ def _build_consumption_breakdown(metrics: dict[str, Any]) -> dict[str, float | N
197
257
  return {k: (float(v) if v is not None else 0.0) for k, v in values.items()}
198
258
 
199
259
 
260
+ # pylint: disable=too-many-locals
200
261
  def render_summary_boxes(
201
262
  metrics: dict[str, Any],
202
263
  component_types: Iterable[str] | None = None,
264
+ microgrid_id: int | None = None,
203
265
  ) -> None:
204
266
  """Render overview metrics grouped into styled subsections.
205
267
 
@@ -207,6 +269,8 @@ def render_summary_boxes(
207
269
  metrics: Metrics dictionary containing aggregated KPI values.
208
270
  component_types: Optional iterable of component type identifiers present
209
271
  in the microgrid (e.g., ``{"pv", "chp"}``).
272
+ microgrid_id: Optional microgrid identifier used for microgrid-specific
273
+ KPI cards.
210
274
 
211
275
  Returns:
212
276
  Streamlit components are rendered directly.
@@ -230,29 +294,49 @@ def render_summary_boxes(
230
294
  )
231
295
 
232
296
  for section in SECTION_SPECS:
297
+ box_specs = section["boxes"]
298
+ if component_type_set:
299
+ box_specs = [
300
+ spec
301
+ for spec in box_specs
302
+ if spec.get("component_type") is None
303
+ or spec.get("component_type") in component_type_set
304
+ ]
305
+ box_specs = [
306
+ spec
307
+ for spec in box_specs
308
+ if spec.get("microgrid_ids") is None
309
+ or microgrid_id in spec.get("microgrid_ids", set())
310
+ ]
311
+ if not box_specs:
312
+ continue
313
+
233
314
  title = section["title"]
234
315
  accent = _SECTION_ACCENTS.get(title, "#3b82f6")
235
316
  icon = _SECTION_ICONS.get(title, "●")
236
317
  icon_bg = accent + "22" # ~13% opacity hex approximation
318
+ warning = section.get("warning")
319
+ warning_html = (
320
+ f'<span class="kpi-section-warning" role="button" tabindex="0" '
321
+ f'aria-label="{warning}">'
322
+ '<span class="kpi-section-warning__icon">⚠</span>'
323
+ f'<span class="kpi-section-warning__tooltip">{warning}</span>'
324
+ "</span>"
325
+ if warning
326
+ else ""
327
+ )
237
328
 
238
329
  st.markdown(
239
330
  f"""
240
331
  <div class="kpi-section-header">
241
332
  <div class="kpi-section-icon" style="background:{icon_bg};">{icon}</div>
242
333
  <p class="kpi-section-title">{title}</p>
334
+ {warning_html}
243
335
  </div>
244
336
  """,
245
337
  unsafe_allow_html=True,
246
338
  )
247
339
 
248
- box_specs = section["boxes"]
249
- if component_type_set:
250
- box_specs = [
251
- spec
252
- for spec in box_specs
253
- if spec.get("component_type") is None
254
- or spec.get("component_type") in component_type_set
255
- ]
256
340
  boxes = _materialize_boxes(box_specs, metrics)
257
341
  per_row = section.get("per_row", 3)
258
342
  render_box_grid(boxes, per_row=per_row, accent=accent)