pyconvexity 0.4.8__py3-none-any.whl

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.

Potentially problematic release.


This version of pyconvexity might be problematic. Click here for more details.

Files changed (44) hide show
  1. pyconvexity/__init__.py +241 -0
  2. pyconvexity/_version.py +1 -0
  3. pyconvexity/core/__init__.py +60 -0
  4. pyconvexity/core/database.py +485 -0
  5. pyconvexity/core/errors.py +106 -0
  6. pyconvexity/core/types.py +400 -0
  7. pyconvexity/dashboard.py +265 -0
  8. pyconvexity/data/README.md +101 -0
  9. pyconvexity/data/__init__.py +17 -0
  10. pyconvexity/data/loaders/__init__.py +3 -0
  11. pyconvexity/data/loaders/cache.py +213 -0
  12. pyconvexity/data/schema/01_core_schema.sql +420 -0
  13. pyconvexity/data/schema/02_data_metadata.sql +120 -0
  14. pyconvexity/data/schema/03_validation_data.sql +507 -0
  15. pyconvexity/data/sources/__init__.py +5 -0
  16. pyconvexity/data/sources/gem.py +442 -0
  17. pyconvexity/io/__init__.py +26 -0
  18. pyconvexity/io/excel_exporter.py +1226 -0
  19. pyconvexity/io/excel_importer.py +1381 -0
  20. pyconvexity/io/netcdf_exporter.py +191 -0
  21. pyconvexity/io/netcdf_importer.py +1802 -0
  22. pyconvexity/models/__init__.py +195 -0
  23. pyconvexity/models/attributes.py +730 -0
  24. pyconvexity/models/carriers.py +159 -0
  25. pyconvexity/models/components.py +611 -0
  26. pyconvexity/models/network.py +503 -0
  27. pyconvexity/models/results.py +148 -0
  28. pyconvexity/models/scenarios.py +234 -0
  29. pyconvexity/solvers/__init__.py +29 -0
  30. pyconvexity/solvers/pypsa/__init__.py +30 -0
  31. pyconvexity/solvers/pypsa/api.py +446 -0
  32. pyconvexity/solvers/pypsa/batch_loader.py +296 -0
  33. pyconvexity/solvers/pypsa/builder.py +655 -0
  34. pyconvexity/solvers/pypsa/clearing_price.py +678 -0
  35. pyconvexity/solvers/pypsa/constraints.py +405 -0
  36. pyconvexity/solvers/pypsa/solver.py +1442 -0
  37. pyconvexity/solvers/pypsa/storage.py +2096 -0
  38. pyconvexity/timeseries.py +330 -0
  39. pyconvexity/validation/__init__.py +25 -0
  40. pyconvexity/validation/rules.py +312 -0
  41. pyconvexity-0.4.8.dist-info/METADATA +148 -0
  42. pyconvexity-0.4.8.dist-info/RECORD +44 -0
  43. pyconvexity-0.4.8.dist-info/WHEEL +5 -0
  44. pyconvexity-0.4.8.dist-info/top_level.txt +1 -0
@@ -0,0 +1,400 @@
1
+ """
2
+ Core data types for PyConvexity.
3
+
4
+ These types mirror the Rust implementation while providing Python-specific
5
+ enhancements and better type safety.
6
+ """
7
+
8
+ import json
9
+ from dataclasses import dataclass
10
+ from typing import Dict, Any, Optional, List, Union
11
+
12
+
13
+ class StaticValue:
14
+ """
15
+ Represents a static (non-time-varying) attribute value.
16
+
17
+ Mirrors the Rust StaticValue enum while providing Python conveniences.
18
+ Supports float, int, bool, and string values with proper type conversion.
19
+ """
20
+
21
+ def __init__(self, value: Union[float, int, bool, str]):
22
+ # Check bool before int since bool is subclass of int in Python
23
+ if isinstance(value, bool):
24
+ self.data = {"Boolean": value}
25
+ elif isinstance(value, float):
26
+ self.data = {"Float": value}
27
+ elif isinstance(value, int):
28
+ self.data = {"Integer": value}
29
+ elif isinstance(value, str):
30
+ self.data = {"String": value}
31
+ else:
32
+ raise ValueError(f"Unsupported value type: {type(value)}")
33
+
34
+ def to_json(self) -> str:
35
+ """
36
+ Return raw value as JSON to match Rust serialization format.
37
+
38
+ Rust stores: 123.45, 42, true, "hello"
39
+ Not: {"Float": 123.45}, {"Integer": 42}, etc.
40
+ """
41
+ import math
42
+
43
+ if "Float" in self.data:
44
+ float_val = self.data["Float"]
45
+ # Ensure finite values only
46
+ if not math.isfinite(float_val):
47
+ raise ValueError(
48
+ f"Cannot serialize non-finite float value: {float_val}"
49
+ )
50
+ return json.dumps(float_val)
51
+ elif "Integer" in self.data:
52
+ return json.dumps(self.data["Integer"])
53
+ elif "Boolean" in self.data:
54
+ return json.dumps(self.data["Boolean"])
55
+ elif "String" in self.data:
56
+ return json.dumps(self.data["String"])
57
+ else:
58
+ # Fallback to original format if unknown
59
+ return json.dumps(self.data)
60
+
61
+ def data_type(self) -> str:
62
+ """Get data type name - mirrors Rust implementation"""
63
+ if "Float" in self.data:
64
+ return "float"
65
+ elif "Integer" in self.data:
66
+ return "int"
67
+ elif "Boolean" in self.data:
68
+ return "boolean"
69
+ elif "String" in self.data:
70
+ return "string"
71
+ else:
72
+ return "unknown"
73
+
74
+ def as_f64(self) -> float:
75
+ """Convert to float, mirroring Rust implementation"""
76
+ if "Float" in self.data:
77
+ return self.data["Float"]
78
+ elif "Integer" in self.data:
79
+ return float(self.data["Integer"])
80
+ elif "Boolean" in self.data:
81
+ return 1.0 if self.data["Boolean"] else 0.0
82
+ else:
83
+ try:
84
+ return float(self.data["String"])
85
+ except ValueError:
86
+ return 0.0
87
+
88
+ def value(self) -> Union[float, int, bool, str]:
89
+ """Get the raw Python value"""
90
+ if "Float" in self.data:
91
+ return self.data["Float"]
92
+ elif "Integer" in self.data:
93
+ return self.data["Integer"]
94
+ elif "Boolean" in self.data:
95
+ return self.data["Boolean"]
96
+ elif "String" in self.data:
97
+ return self.data["String"]
98
+ else:
99
+ raise ValueError("Unknown data type in StaticValue")
100
+
101
+ def __repr__(self) -> str:
102
+ return f"StaticValue({self.value()})"
103
+
104
+ def __eq__(self, other) -> bool:
105
+ if isinstance(other, StaticValue):
106
+ return self.data == other.data
107
+ return False
108
+
109
+
110
+ @dataclass
111
+ class Timeseries:
112
+ """
113
+ Efficient timeseries data structure matching the new Rust implementation.
114
+
115
+ Stores values as a flat array for maximum performance, matching the
116
+ unified Rust Timeseries struct.
117
+ """
118
+
119
+ values: List[float]
120
+ length: int
121
+ start_index: int
122
+ data_type: str
123
+ unit: Optional[str]
124
+ is_input: bool
125
+
126
+ def __post_init__(self):
127
+ # Ensure length matches values array
128
+ self.length = len(self.values)
129
+ # Ensure all values are float32-compatible
130
+ self.values = [float(v) for v in self.values]
131
+
132
+ def get_value(self, index: int) -> Optional[float]:
133
+ """Get value at specific index."""
134
+ if 0 <= index < len(self.values):
135
+ return self.values[index]
136
+ return None
137
+
138
+ def get_range(self, start: int, end: int) -> List[float]:
139
+ """Get a range of values efficiently."""
140
+ end = min(end, len(self.values))
141
+ start = min(start, end)
142
+ return self.values[start:end]
143
+
144
+ def sample(self, max_points: int) -> "Timeseries":
145
+ """Apply sampling if the timeseries is too large."""
146
+ if len(self.values) <= max_points:
147
+ return self
148
+
149
+ step = len(self.values) // max_points
150
+ sampled_values = []
151
+
152
+ for i in range(0, len(self.values), max(1, step)):
153
+ sampled_values.append(self.values[i])
154
+
155
+ # Always include the last point if not already included
156
+ if self.values and sampled_values[-1] != self.values[-1]:
157
+ sampled_values.append(self.values[-1])
158
+
159
+ return Timeseries(
160
+ values=sampled_values,
161
+ length=len(sampled_values),
162
+ start_index=self.start_index,
163
+ data_type=self.data_type,
164
+ unit=self.unit,
165
+ is_input=self.is_input,
166
+ )
167
+
168
+ def slice(self, start_index: int, end_index: int) -> "Timeseries":
169
+ """Apply range filtering."""
170
+ start = max(0, start_index - self.start_index)
171
+ end = max(0, end_index - self.start_index)
172
+ end = min(end, len(self.values))
173
+ start = min(start, end)
174
+
175
+ return Timeseries(
176
+ values=self.values[start:end],
177
+ length=end - start,
178
+ start_index=self.start_index + start,
179
+ data_type=self.data_type,
180
+ unit=self.unit,
181
+ is_input=self.is_input,
182
+ )
183
+
184
+
185
+ @dataclass
186
+ class TimeseriesMetadata:
187
+ """
188
+ Metadata about a timeseries without loading the full data.
189
+
190
+ Mirrors Rust TimeseriesMetadata struct.
191
+ """
192
+
193
+ length: int
194
+ start_time: int
195
+ end_time: int
196
+ start_index: int
197
+ end_index: int
198
+ data_type: str
199
+ unit: Optional[str]
200
+ is_input: bool
201
+
202
+
203
+ @dataclass
204
+ class TimePeriod:
205
+ """
206
+ Represents a time period in the network's time axis.
207
+
208
+ Mirrors Rust TimePeriod structure.
209
+ """
210
+
211
+ timestamp: int
212
+ period_index: int
213
+ formatted_time: str
214
+
215
+
216
+ @dataclass
217
+ class TimeseriesValidationResult:
218
+ """
219
+ Result of validating timeseries alignment with network time periods.
220
+
221
+ Mirrors Rust TimeseriesValidationResult.
222
+ """
223
+
224
+ is_valid: bool
225
+ missing_periods: List[int]
226
+ extra_periods: List[int]
227
+ total_network_periods: int
228
+ provided_periods: int
229
+
230
+
231
+ @dataclass
232
+ class ValidationRule:
233
+ """
234
+ Validation rule for component attributes.
235
+
236
+ Mirrors Rust ValidationRule with all fields.
237
+ """
238
+
239
+ component_type: str
240
+ attribute_name: str
241
+ data_type: str
242
+ unit: Optional[str]
243
+ default_value_string: Optional[str]
244
+ allowed_storage_types: str
245
+ allows_static: bool
246
+ allows_timeseries: bool
247
+ is_required: bool
248
+ is_input: bool
249
+ description: Optional[str]
250
+ default_value: Optional[StaticValue]
251
+
252
+
253
+ class AttributeValue:
254
+ """
255
+ Represents either a static value or timeseries data for a component attribute.
256
+
257
+ Uses efficient Timeseries format for optimal performance.
258
+ Mirrors Rust AttributeValue enum.
259
+ """
260
+
261
+ def __init__(self, value: Union[StaticValue, Timeseries]):
262
+ if isinstance(value, StaticValue):
263
+ self.variant = "Static"
264
+ self.static_value = value
265
+ self.timeseries_value = None
266
+ elif isinstance(value, Timeseries):
267
+ self.variant = "Timeseries"
268
+ self.static_value = None
269
+ self.timeseries_value = value
270
+ else:
271
+ raise ValueError(
272
+ f"AttributeValue must be StaticValue or Timeseries, got {type(value)}"
273
+ )
274
+
275
+ @classmethod
276
+ def static(cls, value: StaticValue) -> "AttributeValue":
277
+ """Create a static attribute value"""
278
+ return cls(value)
279
+
280
+ @classmethod
281
+ def timeseries(cls, timeseries: Timeseries) -> "AttributeValue":
282
+ """Create a timeseries attribute value (new format)"""
283
+ return cls(timeseries)
284
+
285
+ def is_static(self) -> bool:
286
+ """Check if this is a static value"""
287
+ return self.variant == "Static"
288
+
289
+ def is_timeseries(self) -> bool:
290
+ """Check if this is a timeseries value"""
291
+ return self.variant == "Timeseries"
292
+
293
+ def as_timeseries(self) -> Optional[Timeseries]:
294
+ """Get the timeseries data in new format"""
295
+ return self.timeseries_value if self.is_timeseries() else None
296
+
297
+ def __repr__(self) -> str:
298
+ if self.is_static():
299
+ return f"AttributeValue.static({self.static_value})"
300
+ else:
301
+ length = len(self.timeseries_value.values) if self.timeseries_value else 0
302
+ return f"AttributeValue.timeseries({length} points)"
303
+
304
+
305
+ @dataclass
306
+ class Component:
307
+ """
308
+ Represents a component in the energy system model (single network per database).
309
+
310
+ Mirrors Rust Component struct.
311
+ """
312
+
313
+ id: int
314
+ component_type: str
315
+ name: str
316
+ longitude: Optional[float] = None
317
+ latitude: Optional[float] = None
318
+ carrier_id: Optional[int] = None
319
+ bus_id: Optional[int] = None
320
+ bus0_id: Optional[int] = None
321
+ bus1_id: Optional[int] = None
322
+
323
+
324
+ @dataclass
325
+ class Network:
326
+ """
327
+ Represents a network/model in the system.
328
+
329
+ Enhanced version of network information with additional metadata.
330
+ """
331
+
332
+ id: int
333
+ name: str
334
+ description: Optional[str] = None
335
+ time_start: Optional[str] = None
336
+ time_end: Optional[str] = None
337
+ time_interval: Optional[str] = None
338
+ created_at: Optional[str] = None
339
+ updated_at: Optional[str] = None
340
+
341
+
342
+ @dataclass
343
+ class CreateComponentRequest:
344
+ """
345
+ Request structure for creating a new component (single network per database).
346
+
347
+ Mirrors Rust CreateComponentRequest.
348
+ """
349
+
350
+ component_type: str
351
+ name: str
352
+ description: Optional[str] = None
353
+ longitude: Optional[float] = None
354
+ latitude: Optional[float] = None
355
+ carrier_id: Optional[int] = None
356
+ bus_id: Optional[int] = None
357
+ bus0_id: Optional[int] = None
358
+ bus1_id: Optional[int] = None
359
+
360
+
361
+ @dataclass
362
+ class CreateNetworkRequest:
363
+ """
364
+ Request structure for creating a new network.
365
+
366
+ Mirrors Rust CreateNetworkRequest.
367
+ """
368
+
369
+ name: str
370
+ description: Optional[str] = None
371
+ time_resolution: Optional[str] = None
372
+ start_time: Optional[str] = None
373
+ end_time: Optional[str] = None
374
+
375
+
376
+ @dataclass
377
+ class Carrier:
378
+ """
379
+ Represents an energy carrier (e.g., electricity, heat, gas).
380
+ """
381
+
382
+ id: int
383
+ name: str
384
+ co2_emissions: float = 0.0
385
+ color: Optional[str] = None
386
+ nice_name: Optional[str] = None
387
+
388
+
389
+ @dataclass
390
+ class Scenario:
391
+ """
392
+ Represents a scenario within a network.
393
+ """
394
+
395
+ id: int
396
+ name: str
397
+ description: Optional[str] = None
398
+ is_master: bool = False
399
+ created_at: Optional[str] = None
400
+ updated_at: Optional[str] = None
@@ -0,0 +1,265 @@
1
+ """
2
+ Dashboard configuration for Convexity app visualization.
3
+
4
+ Allows programmatic configuration of the analytics dashboard layout
5
+ that will be displayed when the model is loaded in the Convexity app.
6
+
7
+ Example:
8
+ >>> from pyconvexity.dashboard import set_dashboard_config, DashboardConfig, auto_layout
9
+ >>>
10
+ >>> charts = [
11
+ ... {
12
+ ... "id": "dispatch-1",
13
+ ... "title": "Generation by Carrier",
14
+ ... "visible": True,
15
+ ... "view": {
16
+ ... "timeseries": {
17
+ ... "component": "Generator",
18
+ ... "attribute": "p",
19
+ ... "group_by": "carrier"
20
+ ... }
21
+ ... }
22
+ ... },
23
+ ... {
24
+ ... "id": "lmp-1",
25
+ ... "title": "Locational Marginal Prices",
26
+ ... "visible": True,
27
+ ... "view": {
28
+ ... "timeseries": {
29
+ ... "component": "Bus",
30
+ ... "attribute": "marginal_price"
31
+ ... }
32
+ ... }
33
+ ... }
34
+ ... ]
35
+ >>>
36
+ >>> config = DashboardConfig(charts=charts, layout=auto_layout(charts))
37
+ >>> set_dashboard_config(conn, config)
38
+ """
39
+
40
+ import json
41
+ import sqlite3
42
+ from dataclasses import dataclass, field, asdict
43
+ from typing import Any, Dict, List, Optional
44
+
45
+
46
+ @dataclass
47
+ class DashboardConfig:
48
+ """
49
+ Dashboard configuration for the Convexity app analytics view.
50
+
51
+ Attributes:
52
+ charts: List of chart configurations. Each chart is a dict with:
53
+ - id: Unique identifier (e.g., "dispatch-1", "lmp-main")
54
+ - title: Display title
55
+ - visible: Whether chart is shown
56
+ - view: Chart type configuration (see below)
57
+ - filters: Optional chart-specific filters
58
+
59
+ layout: List of layout positions. Each position is a dict with:
60
+ - i: Chart ID (must match a chart's id)
61
+ - x: Grid column (0-11)
62
+ - y: Grid row
63
+ - w: Width in grid units (max 12)
64
+ - h: Height in grid units
65
+
66
+ selected_scenario_id: Pre-selected scenario ID (optional)
67
+ selected_ensemble_name: Pre-selected ensemble name (optional)
68
+ selected_bus_id: Pre-selected bus ID for filtering (optional)
69
+
70
+ Chart View Types:
71
+ Timeseries (dispatch, LMP, etc.):
72
+ {"timeseries": {"component": "Generator", "attribute": "p", "group_by": "carrier"}}
73
+ {"timeseries": {"component": "Bus", "attribute": "marginal_price"}}
74
+ {"timeseries": {"component": "Load", "attribute": "p"}}
75
+
76
+ Network map:
77
+ {"network": {"network": true}}
78
+
79
+ Statistics/Summary:
80
+ {"statistic": {"statistic": "optimal_capacity", "metric": "capacity"}}
81
+ {"statistic": {"statistic": "total_cost"}}
82
+ """
83
+ charts: List[Dict[str, Any]]
84
+ layout: List[Dict[str, Any]]
85
+ selected_scenario_id: Optional[int] = None
86
+ selected_ensemble_name: Optional[str] = None
87
+ selected_bus_id: Optional[int] = None
88
+
89
+ def to_dict(self) -> Dict[str, Any]:
90
+ """Convert to dictionary for JSON serialization."""
91
+ result = {
92
+ "charts": self.charts,
93
+ "layout": self.layout,
94
+ }
95
+ # Only include optional fields if set (matches Rust's skip_serializing_if)
96
+ if self.selected_scenario_id is not None:
97
+ result["selected_scenario_id"] = self.selected_scenario_id
98
+ if self.selected_ensemble_name is not None:
99
+ result["selected_ensemble_name"] = self.selected_ensemble_name
100
+ if self.selected_bus_id is not None:
101
+ result["selected_bus_id"] = self.selected_bus_id
102
+ return result
103
+
104
+
105
+ def set_dashboard_config(conn: sqlite3.Connection, config: DashboardConfig) -> int:
106
+ """
107
+ Save dashboard configuration to the database.
108
+
109
+ This configuration will be loaded by the Convexity app when the model
110
+ is opened, setting up the analytics dashboard with the specified charts
111
+ and layout.
112
+
113
+ Args:
114
+ conn: Database connection
115
+ config: Dashboard configuration
116
+
117
+ Returns:
118
+ Row ID of the stored configuration
119
+
120
+ Example:
121
+ >>> config = DashboardConfig(
122
+ ... charts=[{"id": "dispatch-1", "title": "Dispatch", "visible": True,
123
+ ... "view": {"timeseries": {"component": "Generator", "attribute": "p", "group_by": "carrier"}}}],
124
+ ... layout=[{"i": "dispatch-1", "x": 0, "y": 0, "w": 12, "h": 40}]
125
+ ... )
126
+ >>> set_dashboard_config(conn, config)
127
+ """
128
+ data_json = json.dumps(config.to_dict())
129
+ data_bytes = data_json.encode('utf-8')
130
+
131
+ # Check if analytics config exists
132
+ cursor = conn.execute(
133
+ "SELECT id FROM network_data_store WHERE category = 'analytics_view' AND name = 'default'"
134
+ )
135
+ row = cursor.fetchone()
136
+
137
+ if row:
138
+ # Update existing
139
+ row_id = row[0]
140
+ conn.execute(
141
+ "UPDATE network_data_store SET data = ?, updated_at = datetime('now') WHERE id = ?",
142
+ (data_bytes, row_id)
143
+ )
144
+ else:
145
+ # Insert new
146
+ conn.execute(
147
+ """INSERT INTO network_data_store (category, name, data_format, data, created_at, updated_at)
148
+ VALUES ('analytics_view', 'default', 'json', ?, datetime('now'), datetime('now'))""",
149
+ (data_bytes,)
150
+ )
151
+ row_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
152
+
153
+ conn.commit()
154
+ return row_id
155
+
156
+
157
+ def get_dashboard_config(conn: sqlite3.Connection) -> Optional[DashboardConfig]:
158
+ """
159
+ Get the current dashboard configuration from the database.
160
+
161
+ Args:
162
+ conn: Database connection
163
+
164
+ Returns:
165
+ DashboardConfig if one exists, None otherwise
166
+ """
167
+ cursor = conn.execute(
168
+ """SELECT data FROM network_data_store
169
+ WHERE category = 'analytics_view'
170
+ ORDER BY updated_at DESC LIMIT 1"""
171
+ )
172
+ row = cursor.fetchone()
173
+
174
+ if not row:
175
+ return None
176
+
177
+ data_bytes = row[0]
178
+ if isinstance(data_bytes, bytes):
179
+ data_str = data_bytes.decode('utf-8')
180
+ else:
181
+ data_str = data_bytes
182
+
183
+ data = json.loads(data_str)
184
+
185
+ return DashboardConfig(
186
+ charts=data.get("charts", []),
187
+ layout=data.get("layout", []),
188
+ selected_scenario_id=data.get("selected_scenario_id"),
189
+ selected_ensemble_name=data.get("selected_ensemble_name"),
190
+ selected_bus_id=data.get("selected_bus_id"),
191
+ )
192
+
193
+
194
+ def auto_layout(charts: List[Dict[str, Any]], cols: int = 12) -> List[Dict[str, Any]]:
195
+ """
196
+ Automatically generate layout positions for charts.
197
+
198
+ Places charts in a vertical stack, each taking full width.
199
+ Timeseries charts get height 40, others get height 20.
200
+
201
+ Args:
202
+ charts: List of chart configurations
203
+ cols: Grid columns (default 12)
204
+
205
+ Returns:
206
+ List of layout position dicts
207
+
208
+ Example:
209
+ >>> charts = [
210
+ ... {"id": "dispatch-1", "title": "Dispatch", "visible": True,
211
+ ... "view": {"timeseries": {...}}},
212
+ ... {"id": "network-1", "title": "Network", "visible": True,
213
+ ... "view": {"network": {"network": True}}}
214
+ ... ]
215
+ >>> layout = auto_layout(charts)
216
+ >>> # Returns: [{"i": "dispatch-1", "x": 0, "y": 0, "w": 12, "h": 40}, ...]
217
+ """
218
+ layout = []
219
+ y = 0
220
+
221
+ for chart in charts:
222
+ if not chart.get("visible", True):
223
+ continue
224
+
225
+ chart_id = chart["id"]
226
+ view = chart.get("view", {})
227
+
228
+ # Determine chart dimensions based on type
229
+ if "timeseries" in view:
230
+ w, h = 12, 40
231
+ elif "network" in view:
232
+ w, h = 6, 40
233
+ elif "statistic" in view:
234
+ w, h = 6, 20
235
+ else:
236
+ w, h = 12, 20
237
+
238
+ layout.append({
239
+ "i": chart_id,
240
+ "x": 0,
241
+ "y": y,
242
+ "w": w,
243
+ "h": h,
244
+ })
245
+
246
+ y += h
247
+
248
+ return layout
249
+
250
+
251
+ def clear_dashboard_config(conn: sqlite3.Connection) -> bool:
252
+ """
253
+ Remove any existing dashboard configuration.
254
+
255
+ Args:
256
+ conn: Database connection
257
+
258
+ Returns:
259
+ True if a config was deleted, False if none existed
260
+ """
261
+ cursor = conn.execute(
262
+ "DELETE FROM network_data_store WHERE category = 'analytics_view' AND name = 'default'"
263
+ )
264
+ conn.commit()
265
+ return cursor.rowcount > 0