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,611 @@
1
+ """
2
+ Component management operations for PyConvexity.
3
+
4
+ Provides CRUD operations for energy system components (buses, generators, loads, etc.)
5
+ with proper validation and error handling.
6
+ """
7
+
8
+ import sqlite3
9
+ from typing import Dict, Any, Optional, List
10
+
11
+ from pyconvexity.core.types import Component, CreateComponentRequest, StaticValue
12
+ from pyconvexity.core.errors import ComponentNotFound, ValidationError, DatabaseError
13
+
14
+
15
+ def get_component_type(conn: sqlite3.Connection, component_id: int) -> str:
16
+ """
17
+ Get component type for a component ID.
18
+
19
+ Args:
20
+ conn: Database connection
21
+ component_id: ID of the component
22
+
23
+ Returns:
24
+ Component type string (e.g., "BUS", "GENERATOR")
25
+
26
+ Raises:
27
+ ComponentNotFound: If component doesn't exist
28
+ """
29
+ cursor = conn.execute(
30
+ "SELECT component_type FROM components WHERE id = ?", (component_id,)
31
+ )
32
+ row = cursor.fetchone()
33
+ if not row:
34
+ raise ComponentNotFound(component_id)
35
+ return row[0]
36
+
37
+
38
+ def get_component(conn: sqlite3.Connection, component_id: int) -> Component:
39
+ """
40
+ Get component by ID (single network per database).
41
+
42
+ Args:
43
+ conn: Database connection
44
+ component_id: ID of the component
45
+
46
+ Returns:
47
+ Component object with all fields populated
48
+
49
+ Raises:
50
+ ComponentNotFound: If component doesn't exist
51
+ """
52
+ cursor = conn.execute(
53
+ """
54
+ SELECT id, component_type, name, longitude, latitude,
55
+ carrier_id, bus_id, bus0_id, bus1_id
56
+ FROM components WHERE id = ?
57
+ """,
58
+ (component_id,),
59
+ )
60
+
61
+ row = cursor.fetchone()
62
+ if not row:
63
+ raise ComponentNotFound(component_id)
64
+
65
+ return Component(
66
+ id=row[0],
67
+ component_type=row[1],
68
+ name=row[2],
69
+ longitude=row[3],
70
+ latitude=row[4],
71
+ carrier_id=row[5],
72
+ bus_id=row[6],
73
+ bus0_id=row[7],
74
+ bus1_id=row[8],
75
+ )
76
+
77
+
78
+ def list_components_by_type(
79
+ conn: sqlite3.Connection, component_type: Optional[str] = None
80
+ ) -> List[Component]:
81
+ """
82
+ List components by type (single network per database).
83
+
84
+ Args:
85
+ conn: Database connection
86
+ component_type: Optional component type filter (e.g., "BUS", "GENERATOR")
87
+
88
+ Returns:
89
+ List of Component objects
90
+ """
91
+ if component_type:
92
+ cursor = conn.execute(
93
+ """
94
+ SELECT id, component_type, name, longitude, latitude,
95
+ carrier_id, bus_id, bus0_id, bus1_id
96
+ FROM components
97
+ WHERE component_type = ?
98
+ ORDER BY name
99
+ """,
100
+ (component_type.upper(),),
101
+ )
102
+ else:
103
+ cursor = conn.execute(
104
+ """
105
+ SELECT id, component_type, name, longitude, latitude,
106
+ carrier_id, bus_id, bus0_id, bus1_id
107
+ FROM components
108
+ ORDER BY component_type, name
109
+ """
110
+ )
111
+
112
+ components = []
113
+ for row in cursor.fetchall():
114
+ components.append(
115
+ Component(
116
+ id=row[0],
117
+ component_type=row[1],
118
+ name=row[2],
119
+ longitude=row[3],
120
+ latitude=row[4],
121
+ carrier_id=row[5],
122
+ bus_id=row[6],
123
+ bus0_id=row[7],
124
+ bus1_id=row[8],
125
+ )
126
+ )
127
+
128
+ return components
129
+
130
+
131
+ def insert_component(conn: sqlite3.Connection, request: CreateComponentRequest) -> int:
132
+ """
133
+ Insert a new component (single network per database).
134
+
135
+ Args:
136
+ conn: Database connection
137
+ request: Component creation request with all necessary fields
138
+
139
+ Returns:
140
+ ID of the newly created component
141
+
142
+ Raises:
143
+ DatabaseError: If insertion fails
144
+ ValidationError: If required fields are missing
145
+ """
146
+ # Determine carrier_id - use provided value or auto-assign default
147
+ # CONSTRAINT components must have carrier_id=None per database schema
148
+ carrier_id = request.carrier_id
149
+ if carrier_id is None and request.component_type.upper() != "CONSTRAINT":
150
+ carrier_id = get_default_carrier_id(conn, request.component_type)
151
+ elif request.component_type.upper() == "CONSTRAINT":
152
+ carrier_id = None # Explicitly keep None for constraints
153
+
154
+ # Insert the component
155
+ cursor = conn.execute(
156
+ """
157
+ INSERT INTO components (
158
+ component_type, name, longitude, latitude,
159
+ carrier_id, bus_id, bus0_id, bus1_id,
160
+ created_at, updated_at
161
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
162
+ """,
163
+ (
164
+ request.component_type,
165
+ request.name,
166
+ request.longitude,
167
+ request.latitude,
168
+ carrier_id,
169
+ request.bus_id,
170
+ request.bus0_id,
171
+ request.bus1_id,
172
+ ),
173
+ )
174
+
175
+ component_id = cursor.lastrowid
176
+ if not component_id:
177
+ raise DatabaseError("Failed to create component")
178
+
179
+ # If description is provided, store it as an attribute
180
+ if request.description:
181
+ from pyconvexity.models.attributes import set_static_attribute
182
+
183
+ set_static_attribute(
184
+ conn, component_id, "description", StaticValue(request.description)
185
+ )
186
+
187
+ # If this is a BUS, ensure unmet load exists
188
+ if request.component_type.upper() == "BUS":
189
+ # Get unmet_load_active flag from network config
190
+ from pyconvexity.models.network import get_network_config
191
+
192
+ network_config = get_network_config(conn)
193
+ unmet_load_active = network_config.get("unmet_load_active", True)
194
+
195
+ ensure_unmet_load_for_bus(conn, component_id, request.name, unmet_load_active)
196
+
197
+ return component_id
198
+
199
+
200
+ def create_component(
201
+ conn: sqlite3.Connection,
202
+ component_type: str,
203
+ name: str,
204
+ description: Optional[str] = None,
205
+ longitude: Optional[float] = None,
206
+ latitude: Optional[float] = None,
207
+ carrier_id: Optional[int] = None,
208
+ bus_id: Optional[int] = None,
209
+ bus0_id: Optional[int] = None,
210
+ bus1_id: Optional[int] = None,
211
+ ) -> int:
212
+ """
213
+ Create a component and return its ID - convenience function (single network per database).
214
+
215
+ Args:
216
+ conn: Database connection
217
+ component_type: Type of component (e.g., "BUS", "GENERATOR")
218
+ name: Component name
219
+ description: Optional description
220
+ longitude: Optional longitude coordinate
221
+ latitude: Optional latitude coordinate
222
+ carrier_id: Optional carrier ID
223
+ bus_id: Optional bus ID (for single-bus components)
224
+ bus0_id: Optional first bus ID (for two-bus components)
225
+ bus1_id: Optional second bus ID (for two-bus components)
226
+
227
+ Returns:
228
+ ID of the newly created component
229
+ """
230
+ request = CreateComponentRequest(
231
+ component_type=component_type,
232
+ name=name,
233
+ description=description,
234
+ longitude=longitude,
235
+ latitude=latitude,
236
+ carrier_id=carrier_id,
237
+ bus_id=bus_id,
238
+ bus0_id=bus0_id,
239
+ bus1_id=bus1_id,
240
+ )
241
+ return insert_component(conn, request)
242
+
243
+
244
+ def update_component(
245
+ conn: sqlite3.Connection,
246
+ component_id: int,
247
+ name: Optional[str] = None,
248
+ description: Optional[str] = None,
249
+ longitude: Optional[float] = None,
250
+ latitude: Optional[float] = None,
251
+ carrier_id: Optional[int] = None,
252
+ bus_id: Optional[int] = None,
253
+ bus0_id: Optional[int] = None,
254
+ bus1_id: Optional[int] = None,
255
+ ) -> None:
256
+ """
257
+ Update a component.
258
+
259
+ Args:
260
+ conn: Database connection
261
+ component_id: ID of component to update
262
+ name: New name (optional)
263
+ description: New description (optional)
264
+ longitude: New longitude (optional)
265
+ latitude: New latitude (optional)
266
+ carrier_id: New carrier ID (optional)
267
+ bus_id: New bus ID (optional)
268
+ bus0_id: New first bus ID (optional)
269
+ bus1_id: New second bus ID (optional)
270
+
271
+ Raises:
272
+ ComponentNotFound: If component doesn't exist
273
+ """
274
+ # Build dynamic SQL based on what fields are being updated
275
+ set_clauses = []
276
+ params = []
277
+
278
+ if name is not None:
279
+ set_clauses.append("name = ?")
280
+ params.append(name)
281
+ if longitude is not None:
282
+ set_clauses.append("longitude = ?")
283
+ params.append(longitude)
284
+ if latitude is not None:
285
+ set_clauses.append("latitude = ?")
286
+ params.append(latitude)
287
+ if carrier_id is not None:
288
+ set_clauses.append("carrier_id = ?")
289
+ params.append(carrier_id)
290
+ if bus_id is not None:
291
+ set_clauses.append("bus_id = ?")
292
+ params.append(bus_id)
293
+ if bus0_id is not None:
294
+ set_clauses.append("bus0_id = ?")
295
+ params.append(bus0_id)
296
+ if bus1_id is not None:
297
+ set_clauses.append("bus1_id = ?")
298
+ params.append(bus1_id)
299
+
300
+ # Update component table fields if any changes
301
+ if set_clauses:
302
+ set_clauses.append("updated_at = datetime('now')")
303
+ params.append(component_id)
304
+
305
+ sql = f"UPDATE components SET {', '.join(set_clauses)} WHERE id = ?"
306
+ cursor = conn.execute(sql, params)
307
+
308
+ if cursor.rowcount == 0:
309
+ raise ComponentNotFound(component_id)
310
+
311
+ # Handle description as an attribute
312
+ if description is not None:
313
+ from pyconvexity.models.attributes import set_static_attribute, delete_attribute
314
+
315
+ if description == "":
316
+ # Remove description attribute if empty
317
+ try:
318
+ delete_attribute(conn, component_id, "description")
319
+ except: # AttributeNotFound
320
+ pass # Already doesn't exist
321
+ else:
322
+ # Set description as attribute
323
+ set_static_attribute(
324
+ conn, component_id, "description", StaticValue(description)
325
+ )
326
+
327
+
328
+ def delete_component(conn: sqlite3.Connection, component_id: int) -> None:
329
+ """
330
+ Delete a component and all its attributes.
331
+
332
+ Args:
333
+ conn: Database connection
334
+ component_id: ID of component to delete
335
+
336
+ Raises:
337
+ ComponentNotFound: If component doesn't exist
338
+ """
339
+ # First delete all component attributes
340
+ conn.execute(
341
+ "DELETE FROM component_attributes WHERE component_id = ?", (component_id,)
342
+ )
343
+
344
+ # Then delete the component itself
345
+ cursor = conn.execute("DELETE FROM components WHERE id = ?", (component_id,))
346
+
347
+ if cursor.rowcount == 0:
348
+ raise ComponentNotFound(component_id)
349
+
350
+
351
+ def list_component_attributes(conn: sqlite3.Connection, component_id: int) -> List[str]:
352
+ """
353
+ List all attribute names for a component.
354
+
355
+ Args:
356
+ conn: Database connection
357
+ component_id: Component ID
358
+
359
+ Returns:
360
+ List of attribute names
361
+ """
362
+ cursor = conn.execute(
363
+ """
364
+ SELECT attribute_name FROM component_attributes
365
+ WHERE component_id = ? ORDER BY attribute_name
366
+ """,
367
+ (component_id,),
368
+ )
369
+
370
+ return [row[0] for row in cursor.fetchall()]
371
+
372
+
373
+ def get_component_by_name(conn: sqlite3.Connection, name: str) -> Component:
374
+ """
375
+ Get a component by name (single network per database).
376
+
377
+ Args:
378
+ conn: Database connection
379
+ name: Component name
380
+
381
+ Returns:
382
+ Component object
383
+
384
+ Raises:
385
+ ComponentNotFound: If component doesn't exist
386
+ """
387
+ cursor = conn.execute(
388
+ """
389
+ SELECT id, component_type, name, longitude, latitude,
390
+ carrier_id, bus_id, bus0_id, bus1_id
391
+ FROM components
392
+ WHERE name = ?
393
+ """,
394
+ (name,),
395
+ )
396
+
397
+ row = cursor.fetchone()
398
+ if not row:
399
+ raise ComponentNotFound(f"Component '{name}' not found")
400
+
401
+ return Component(
402
+ id=row[0],
403
+ component_type=row[1],
404
+ name=row[2],
405
+ longitude=row[3],
406
+ latitude=row[4],
407
+ carrier_id=row[5],
408
+ bus_id=row[6],
409
+ bus0_id=row[7],
410
+ bus1_id=row[8],
411
+ )
412
+
413
+
414
+ def get_component_id(conn: sqlite3.Connection, name: str) -> int:
415
+ """
416
+ Get component ID by name (single network per database).
417
+
418
+ Args:
419
+ conn: Database connection
420
+ name: Component name
421
+
422
+ Returns:
423
+ Component ID
424
+
425
+ Raises:
426
+ ComponentNotFound: If component doesn't exist
427
+ """
428
+ component = get_component_by_name(conn, name)
429
+ return component.id
430
+
431
+
432
+ def component_exists(conn: sqlite3.Connection, name: str) -> bool:
433
+ """
434
+ Check if a component exists (single network per database).
435
+
436
+ Args:
437
+ conn: Database connection
438
+ name: Component name
439
+
440
+ Returns:
441
+ True if component exists, False otherwise
442
+ """
443
+ cursor = conn.execute(
444
+ """
445
+ SELECT 1 FROM components WHERE name = ?
446
+ """,
447
+ (name,),
448
+ )
449
+
450
+ return cursor.fetchone() is not None
451
+
452
+
453
+ def get_component_carrier_map(
454
+ conn: sqlite3.Connection, component_type: Optional[str] = None
455
+ ) -> Dict[str, str]:
456
+ """
457
+ Get mapping from component names to carrier names (single network per database).
458
+
459
+ Args:
460
+ conn: Database connection
461
+ component_type: Optional component type filter
462
+
463
+ Returns:
464
+ Dictionary mapping component names to carrier names
465
+ """
466
+ if component_type:
467
+ cursor = conn.execute(
468
+ """
469
+ SELECT c.name,
470
+ CASE
471
+ WHEN c.component_type = 'UNMET_LOAD' THEN 'Unmet Load'
472
+ ELSE carr.name
473
+ END as carrier_name
474
+ FROM components c
475
+ LEFT JOIN carriers carr ON c.carrier_id = carr.id
476
+ WHERE c.component_type = ?
477
+ """,
478
+ (component_type.upper(),),
479
+ )
480
+ else:
481
+ cursor = conn.execute(
482
+ """
483
+ SELECT c.name,
484
+ CASE
485
+ WHEN c.component_type = 'UNMET_LOAD' THEN 'Unmet Load'
486
+ ELSE carr.name
487
+ END as carrier_name
488
+ FROM components c
489
+ LEFT JOIN carriers carr ON c.carrier_id = carr.id
490
+ """
491
+ )
492
+
493
+ return {row[0]: row[1] for row in cursor.fetchall()}
494
+
495
+
496
+ # Helper functions
497
+
498
+
499
+ def get_default_carrier_id(conn: sqlite3.Connection, component_type: str) -> int:
500
+ """Get default carrier ID for a component type (single network per database)."""
501
+ # Default carrier names based on PyPSA conventions
502
+ default_carrier_name = {
503
+ "BUS": "AC",
504
+ "GENERATOR": "electricity",
505
+ "LOAD": "electricity",
506
+ "STORAGE_UNIT": "electricity",
507
+ "STORE": "electricity",
508
+ "LINE": "AC",
509
+ "LINK": "AC",
510
+ }.get(component_type.upper(), "AC")
511
+
512
+ # Try to find the default carrier
513
+ cursor = conn.execute(
514
+ """
515
+ SELECT id FROM carriers WHERE name = ? LIMIT 1
516
+ """,
517
+ (default_carrier_name,),
518
+ )
519
+
520
+ row = cursor.fetchone()
521
+ if row:
522
+ return row[0]
523
+
524
+ # If not found, try AC
525
+ cursor = conn.execute(
526
+ """
527
+ SELECT id FROM carriers WHERE name = 'AC' LIMIT 1
528
+ """
529
+ )
530
+
531
+ row = cursor.fetchone()
532
+ if row:
533
+ return row[0]
534
+
535
+ # If still not found, get any carrier
536
+ cursor = conn.execute(
537
+ """
538
+ SELECT id FROM carriers LIMIT 1
539
+ """
540
+ )
541
+
542
+ row = cursor.fetchone()
543
+ if row:
544
+ return row[0]
545
+
546
+ raise DatabaseError("No carriers found in database")
547
+
548
+
549
+ def ensure_unmet_load_for_bus(
550
+ conn: sqlite3.Connection, bus_id: int, bus_name: str, unmet_load_active: bool
551
+ ) -> None:
552
+ """Ensure there is exactly one UNMET_LOAD per bus (single network per database)."""
553
+ # Check if unmet load already exists for this bus
554
+ cursor = conn.execute(
555
+ """
556
+ SELECT id FROM components
557
+ WHERE bus_id = ? AND component_type = 'UNMET_LOAD'
558
+ LIMIT 1
559
+ """,
560
+ (bus_id,),
561
+ )
562
+
563
+ if cursor.fetchone():
564
+ return # Already exists
565
+
566
+ # Get default carrier for generators (unmet loads are treated as generators)
567
+ carrier_id = get_default_carrier_id(conn, "GENERATOR")
568
+
569
+ # Insert unmet load component - sanitize bus name for PyPSA compatibility
570
+ # Remove spaces, periods, and other problematic characters
571
+ sanitized_bus_name = bus_name.replace(" ", "_").replace(".", "_").replace("-", "_")
572
+ name = f"unmet_load_{sanitized_bus_name}"
573
+ cursor = conn.execute(
574
+ """
575
+ INSERT INTO components (
576
+ component_type, name, carrier_id, bus_id,
577
+ created_at, updated_at
578
+ ) VALUES ('UNMET_LOAD', ?, ?, ?, datetime('now'), datetime('now'))
579
+ """,
580
+ (name, carrier_id, bus_id),
581
+ )
582
+
583
+ unmet_load_id = cursor.lastrowid
584
+
585
+ # Set fixed attributes for unmet load
586
+ from pyconvexity.models.attributes import set_static_attribute
587
+
588
+ set_static_attribute(conn, unmet_load_id, "marginal_cost", StaticValue(1e6))
589
+ set_static_attribute(conn, unmet_load_id, "p_nom", StaticValue(1e6))
590
+ set_static_attribute(
591
+ conn, unmet_load_id, "p_max_pu", StaticValue(1.0)
592
+ ) # Can run at full capacity
593
+ set_static_attribute(
594
+ conn, unmet_load_id, "p_min_pu", StaticValue(0.0)
595
+ ) # Can be turned off
596
+ set_static_attribute(
597
+ conn, unmet_load_id, "sign", StaticValue(1.0)
598
+ ) # Positive power sign (generation)
599
+ set_static_attribute(conn, unmet_load_id, "active", StaticValue(unmet_load_active))
600
+
601
+
602
+ def get_bus_name_to_id_map(conn: sqlite3.Connection) -> Dict[str, int]:
603
+ """Get mapping from bus names to component IDs (single network per database)."""
604
+ cursor = conn.execute(
605
+ """
606
+ SELECT name, id FROM components
607
+ WHERE component_type = 'BUS'
608
+ """
609
+ )
610
+
611
+ return {row[0]: row[1] for row in cursor.fetchall()}