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.
- pyconvexity/__init__.py +241 -0
- pyconvexity/_version.py +1 -0
- pyconvexity/core/__init__.py +60 -0
- pyconvexity/core/database.py +485 -0
- pyconvexity/core/errors.py +106 -0
- pyconvexity/core/types.py +400 -0
- pyconvexity/dashboard.py +265 -0
- pyconvexity/data/README.md +101 -0
- pyconvexity/data/__init__.py +17 -0
- pyconvexity/data/loaders/__init__.py +3 -0
- pyconvexity/data/loaders/cache.py +213 -0
- pyconvexity/data/schema/01_core_schema.sql +420 -0
- pyconvexity/data/schema/02_data_metadata.sql +120 -0
- pyconvexity/data/schema/03_validation_data.sql +507 -0
- pyconvexity/data/sources/__init__.py +5 -0
- pyconvexity/data/sources/gem.py +442 -0
- pyconvexity/io/__init__.py +26 -0
- pyconvexity/io/excel_exporter.py +1226 -0
- pyconvexity/io/excel_importer.py +1381 -0
- pyconvexity/io/netcdf_exporter.py +191 -0
- pyconvexity/io/netcdf_importer.py +1802 -0
- pyconvexity/models/__init__.py +195 -0
- pyconvexity/models/attributes.py +730 -0
- pyconvexity/models/carriers.py +159 -0
- pyconvexity/models/components.py +611 -0
- pyconvexity/models/network.py +503 -0
- pyconvexity/models/results.py +148 -0
- pyconvexity/models/scenarios.py +234 -0
- pyconvexity/solvers/__init__.py +29 -0
- pyconvexity/solvers/pypsa/__init__.py +30 -0
- pyconvexity/solvers/pypsa/api.py +446 -0
- pyconvexity/solvers/pypsa/batch_loader.py +296 -0
- pyconvexity/solvers/pypsa/builder.py +655 -0
- pyconvexity/solvers/pypsa/clearing_price.py +678 -0
- pyconvexity/solvers/pypsa/constraints.py +405 -0
- pyconvexity/solvers/pypsa/solver.py +1442 -0
- pyconvexity/solvers/pypsa/storage.py +2096 -0
- pyconvexity/timeseries.py +330 -0
- pyconvexity/validation/__init__.py +25 -0
- pyconvexity/validation/rules.py +312 -0
- pyconvexity-0.4.8.dist-info/METADATA +148 -0
- pyconvexity-0.4.8.dist-info/RECORD +44 -0
- pyconvexity-0.4.8.dist-info/WHEEL +5 -0
- 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
|
pyconvexity/dashboard.py
ADDED
|
@@ -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
|