pyconvexity 0.5.0.post1__tar.gz → 0.5.1__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.

Potentially problematic release.


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

Files changed (54) hide show
  1. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/PKG-INFO +1 -1
  2. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/pyproject.toml +2 -2
  3. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/__init__.py +9 -0
  4. pyconvexity-0.5.1/src/pyconvexity/_version.py +1 -0
  5. pyconvexity-0.5.1/src/pyconvexity/transformations/__init__.py +15 -0
  6. pyconvexity-0.5.1/src/pyconvexity/transformations/api.py +93 -0
  7. pyconvexity-0.5.1/src/pyconvexity/transformations/time_axis.py +721 -0
  8. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity.egg-info/PKG-INFO +1 -1
  9. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity.egg-info/SOURCES.txt +3 -0
  10. pyconvexity-0.5.0.post1/src/pyconvexity/_version.py +0 -1
  11. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/README.md +0 -0
  12. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/setup.cfg +0 -0
  13. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/core/__init__.py +0 -0
  14. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/core/database.py +0 -0
  15. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/core/errors.py +0 -0
  16. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/core/types.py +0 -0
  17. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/dashboard.py +0 -0
  18. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/data/README.md +0 -0
  19. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/data/__init__.py +0 -0
  20. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/data/loaders/__init__.py +0 -0
  21. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/data/loaders/cache.py +0 -0
  22. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/data/schema/01_core_schema.sql +0 -0
  23. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/data/schema/02_data_metadata.sql +0 -0
  24. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/data/schema/03_validation_data.sql +0 -0
  25. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/data/sources/__init__.py +0 -0
  26. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/data/sources/gem.py +0 -0
  27. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/io/__init__.py +0 -0
  28. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/io/excel_exporter.py +0 -0
  29. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/io/excel_importer.py +0 -0
  30. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/io/netcdf_exporter.py +0 -0
  31. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/io/netcdf_importer.py +0 -0
  32. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/models/__init__.py +0 -0
  33. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/models/attributes.py +0 -0
  34. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/models/carriers.py +0 -0
  35. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/models/components.py +0 -0
  36. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/models/network.py +0 -0
  37. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/models/results.py +0 -0
  38. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/models/scenarios.py +0 -0
  39. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/solvers/__init__.py +0 -0
  40. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/solvers/pypsa/__init__.py +0 -0
  41. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/solvers/pypsa/api.py +0 -0
  42. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/solvers/pypsa/batch_loader.py +0 -0
  43. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/solvers/pypsa/builder.py +0 -0
  44. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/solvers/pypsa/clearing_price.py +0 -0
  45. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/solvers/pypsa/constraints.py +0 -0
  46. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/solvers/pypsa/solver.py +0 -0
  47. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/solvers/pypsa/storage.py +0 -0
  48. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/timeseries.py +0 -0
  49. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/validation/__init__.py +0 -0
  50. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity/validation/rules.py +0 -0
  51. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity.egg-info/dependency_links.txt +0 -0
  52. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity.egg-info/requires.txt +0 -0
  53. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/src/pyconvexity.egg-info/top_level.txt +0 -0
  54. {pyconvexity-0.5.0.post1 → pyconvexity-0.5.1}/tests/test_core_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyconvexity
3
- Version: 0.5.0.post1
3
+ Version: 0.5.1
4
4
  Summary: Python library for energy system modeling and optimization with PyPSA
5
5
  Author-email: Convexity Team <info@convexity.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pyconvexity"
7
- version = "0.5.0.post1"
7
+ version = "0.5.1"
8
8
  description = "Python library for energy system modeling and optimization with PyPSA"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -81,7 +81,7 @@ profile = "black"
81
81
  line_length = 88
82
82
 
83
83
  [tool.mypy]
84
- python_version = "0.5.0.post1"
84
+ python_version = "0.5.1"
85
85
  warn_return_any = true
86
86
  warn_unused_configs = true
87
87
  disallow_untyped_defs = true
@@ -239,3 +239,12 @@ try:
239
239
  except ImportError:
240
240
  # NetCDF dependencies not available
241
241
  pass
242
+
243
+ # Transformation operations
244
+ try:
245
+ from pyconvexity.transformations import modify_time_axis
246
+
247
+ __all__.append("modify_time_axis")
248
+ except ImportError:
249
+ # Transformation dependencies (pandas, numpy) not available
250
+ pass
@@ -0,0 +1 @@
1
+ __version__ = "0.5.1"
@@ -0,0 +1,15 @@
1
+ """
2
+ Transformations module for PyConvexity.
3
+
4
+ Provides functions for transforming network data, including:
5
+ - Time axis modification (truncation, resampling)
6
+ - Future: network merging, scenario duplication, etc.
7
+ """
8
+
9
+ from pyconvexity.transformations.api import modify_time_axis
10
+ from pyconvexity.transformations.time_axis import TimeAxisModifier
11
+
12
+ __all__ = [
13
+ "modify_time_axis",
14
+ "TimeAxisModifier",
15
+ ]
@@ -0,0 +1,93 @@
1
+ """
2
+ High-level API for network transformations.
3
+
4
+ Provides user-friendly functions for transforming network data.
5
+ """
6
+
7
+ from typing import Any, Callable, Dict, Optional
8
+
9
+ from pyconvexity.transformations.time_axis import TimeAxisModifier
10
+
11
+
12
+ def modify_time_axis(
13
+ source_db_path: str,
14
+ target_db_path: str,
15
+ new_start: str,
16
+ new_end: str,
17
+ new_resolution_minutes: int,
18
+ new_network_name: Optional[str] = None,
19
+ convert_timeseries: bool = True,
20
+ progress_callback: Optional[Callable[[float, str], None]] = None,
21
+ ) -> Dict[str, Any]:
22
+ """
23
+ Create a new database with modified time axis and resampled timeseries data.
24
+
25
+ This function copies a network database while adjusting the time axis -
26
+ useful for truncating time periods, changing resolution, or both.
27
+ All timeseries data is automatically resampled to match the new time axis.
28
+
29
+ Args:
30
+ source_db_path: Path to source database
31
+ target_db_path: Path to target database (will be created)
32
+ new_start: Start datetime as ISO string (e.g., "2024-01-01 00:00:00")
33
+ new_end: End datetime as ISO string (e.g., "2024-12-31 23:00:00")
34
+ new_resolution_minutes: New time resolution in minutes (e.g., 60 for hourly)
35
+ new_network_name: Optional new name for the network
36
+ convert_timeseries: If True, resample timeseries data to new time axis.
37
+ If False, wipe all timeseries attributes (useful for creating templates)
38
+ progress_callback: Optional callback for progress updates.
39
+ Called with (progress: float, message: str) where progress is 0-100.
40
+
41
+ Returns:
42
+ Dictionary with results and statistics:
43
+ - success: bool - Whether the operation completed successfully
44
+ - source_db_path: str - Path to source database
45
+ - target_db_path: str - Path to created target database
46
+ - new_periods_count: int - Number of time periods in new database
47
+ - new_resolution_minutes: int - Resolution in minutes
48
+ - new_start: str - Start time
49
+ - new_end: str - End time
50
+ - processing_stats: dict - Detailed processing statistics
51
+
52
+ Raises:
53
+ ValueError: If time parameters are invalid (end before start, negative resolution)
54
+ FileNotFoundError: If source database doesn't exist
55
+
56
+ Example:
57
+ # Truncate a yearly model to one week with hourly resolution
58
+ result = modify_time_axis(
59
+ source_db_path="full_year_model.db",
60
+ target_db_path="one_week_model.db",
61
+ new_start="2024-01-01 00:00:00",
62
+ new_end="2024-01-07 23:00:00",
63
+ new_resolution_minutes=60,
64
+ new_network_name="One Week Test Model",
65
+ )
66
+
67
+ if result["success"]:
68
+ print(f"Created {result['target_db_path']} with {result['new_periods_count']} periods")
69
+
70
+ Example with progress tracking:
71
+ def on_progress(progress: float, message: str):
72
+ print(f"[{progress:.0f}%] {message}")
73
+
74
+ result = modify_time_axis(
75
+ source_db_path="original.db",
76
+ target_db_path="resampled.db",
77
+ new_start="2024-01-01",
78
+ new_end="2024-06-30",
79
+ new_resolution_minutes=60,
80
+ progress_callback=on_progress,
81
+ )
82
+ """
83
+ modifier = TimeAxisModifier()
84
+ return modifier.modify_time_axis(
85
+ source_db_path=source_db_path,
86
+ target_db_path=target_db_path,
87
+ new_start=new_start,
88
+ new_end=new_end,
89
+ new_resolution_minutes=new_resolution_minutes,
90
+ new_network_name=new_network_name,
91
+ convert_timeseries=convert_timeseries,
92
+ progress_callback=progress_callback,
93
+ )
@@ -0,0 +1,721 @@
1
+ """
2
+ Time axis modification for PyConvexity networks.
3
+
4
+ Handles truncation and resampling of network time periods and all associated timeseries data.
5
+ """
6
+
7
+ import logging
8
+ import shutil
9
+ from pathlib import Path
10
+ from typing import Any, Callable, Dict, List, Optional, Tuple
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+
15
+ from pyconvexity.core.database import database_context
16
+ from pyconvexity.core.types import TimePeriod
17
+ from pyconvexity.models.network import get_network_info, get_network_time_periods
18
+ from pyconvexity.timeseries import get_timeseries, set_timeseries
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class TimeAxisModifier:
24
+ """
25
+ Service for modifying network time axis and resampling all timeseries data.
26
+
27
+ This class handles the complete workflow of:
28
+ 1. Copying a network database
29
+ 2. Modifying the time axis (start, end, resolution)
30
+ 3. Resampling all timeseries data to match the new time axis
31
+
32
+ Example:
33
+ modifier = TimeAxisModifier()
34
+ result = modifier.modify_time_axis(
35
+ source_db_path="original.db",
36
+ target_db_path="resampled.db",
37
+ new_start="2024-01-01 00:00:00",
38
+ new_end="2024-01-07 23:00:00",
39
+ new_resolution_minutes=60,
40
+ )
41
+ """
42
+
43
+ def __init__(self):
44
+ logger.debug("TimeAxisModifier initialized")
45
+
46
+ def _minutes_to_freq_str(self, minutes: int) -> str:
47
+ """Convert minutes to a pandas frequency string, preferring aliases like 'D' or 'h'."""
48
+ if minutes == 1440:
49
+ return "D"
50
+ if minutes % (60 * 24) == 0:
51
+ days = minutes // (60 * 24)
52
+ return f"{days}D"
53
+ if minutes == 60:
54
+ return "h"
55
+ if minutes % 60 == 0:
56
+ hours = minutes // 60
57
+ return f"{hours}h"
58
+ return f"{minutes}min"
59
+
60
+ def modify_time_axis(
61
+ self,
62
+ source_db_path: str,
63
+ target_db_path: str,
64
+ new_start: str,
65
+ new_end: str,
66
+ new_resolution_minutes: int,
67
+ new_network_name: Optional[str] = None,
68
+ convert_timeseries: bool = True,
69
+ progress_callback: Optional[Callable[[float, str], None]] = None,
70
+ ) -> Dict[str, Any]:
71
+ """
72
+ Create a new database with modified time axis and resampled timeseries data.
73
+
74
+ This method creates a copy of the source database and modifies its time axis,
75
+ optionally resampling all timeseries data to match the new time periods.
76
+
77
+ Args:
78
+ source_db_path: Path to source database
79
+ target_db_path: Path to target database (will be created)
80
+ new_start: Start datetime as ISO string (e.g., "2024-01-01 00:00:00")
81
+ new_end: End datetime as ISO string (e.g., "2024-12-31 23:00:00")
82
+ new_resolution_minutes: New time resolution in minutes (e.g., 60 for hourly)
83
+ new_network_name: Optional new name for the network
84
+ convert_timeseries: If True, resample timeseries data. If False, wipe all timeseries
85
+ progress_callback: Optional callback for progress updates (progress: float, message: str)
86
+
87
+ Returns:
88
+ Dictionary with results and statistics:
89
+ - success: bool
90
+ - source_db_path: str
91
+ - target_db_path: str
92
+ - new_periods_count: int
93
+ - new_resolution_minutes: int
94
+ - new_start: str
95
+ - new_end: str
96
+ - processing_stats: dict with detailed statistics
97
+
98
+ Raises:
99
+ ValueError: If time parameters are invalid
100
+ FileNotFoundError: If source database doesn't exist
101
+ """
102
+
103
+ def update_progress(progress: float, message: str):
104
+ if progress_callback:
105
+ progress_callback(progress, message)
106
+ logger.info(f"[{progress:.1f}%] {message}")
107
+
108
+ try:
109
+ update_progress(0, "Starting time axis modification...")
110
+
111
+ # Validate inputs
112
+ start_dt = pd.Timestamp(new_start)
113
+ end_dt = pd.Timestamp(new_end)
114
+
115
+ if end_dt <= start_dt:
116
+ raise ValueError("End time must be after start time")
117
+
118
+ if new_resolution_minutes <= 0:
119
+ raise ValueError("Time resolution must be positive")
120
+
121
+ update_progress(5, "Validating source database...")
122
+
123
+ # Validate source database and get network info
124
+ with database_context(source_db_path, read_only=True) as source_conn:
125
+ network_info = get_network_info(source_conn)
126
+ if not network_info:
127
+ raise ValueError("No network metadata found in source database")
128
+
129
+ # Generate new time periods
130
+ update_progress(10, "Generating new time axis...")
131
+ new_time_periods = self._generate_time_periods(
132
+ start_dt, end_dt, new_resolution_minutes
133
+ )
134
+ update_progress(15, f"Generated {len(new_time_periods)} new time periods")
135
+
136
+ # Copy database schema and static data
137
+ update_progress(20, "Creating target database...")
138
+ self._copy_database_structure(
139
+ source_db_path,
140
+ target_db_path,
141
+ new_time_periods,
142
+ new_resolution_minutes,
143
+ new_network_name,
144
+ update_progress,
145
+ )
146
+
147
+ # Process timeseries data based on convert_timeseries flag
148
+ if convert_timeseries:
149
+ update_progress(40, "Processing timeseries data...")
150
+ stats = self._process_all_timeseries(
151
+ source_db_path,
152
+ target_db_path,
153
+ new_time_periods,
154
+ new_resolution_minutes,
155
+ update_progress,
156
+ )
157
+ else:
158
+ update_progress(40, "Wiping timeseries data...")
159
+ stats = self._wipe_all_timeseries(target_db_path, update_progress)
160
+
161
+ update_progress(95, "Finalizing database...")
162
+
163
+ # Validate target database
164
+ with database_context(target_db_path, read_only=True) as target_conn:
165
+ target_network_info = get_network_info(target_conn)
166
+ if not target_network_info:
167
+ raise ValueError("Failed to create target network")
168
+
169
+ update_progress(100, "Time axis modification completed successfully")
170
+
171
+ return {
172
+ "success": True,
173
+ "source_db_path": source_db_path,
174
+ "target_db_path": target_db_path,
175
+ "new_periods_count": len(new_time_periods),
176
+ "original_resolution_minutes": None, # Could be calculated from source
177
+ "new_resolution_minutes": new_resolution_minutes,
178
+ "new_start": new_start,
179
+ "new_end": new_end,
180
+ "processing_stats": stats,
181
+ }
182
+
183
+ except Exception as e:
184
+ logger.error(f"Time axis modification failed: {e}", exc_info=True)
185
+
186
+ # Clean up partial target file
187
+ try:
188
+ target_path = Path(target_db_path)
189
+ if target_path.exists():
190
+ target_path.unlink()
191
+ logger.info(f"Cleaned up partial target database: {target_db_path}")
192
+ except Exception as cleanup_error:
193
+ logger.warning(
194
+ f"Failed to clean up partial target database: {cleanup_error}"
195
+ )
196
+
197
+ raise
198
+
199
+ def _generate_time_periods(
200
+ self, start: pd.Timestamp, end: pd.Timestamp, resolution_minutes: int
201
+ ) -> List[TimePeriod]:
202
+ """Generate new time periods based on start, end, and resolution."""
203
+
204
+ # Create time range
205
+ freq_str = self._minutes_to_freq_str(resolution_minutes)
206
+ timestamps = pd.date_range(
207
+ start=start, end=end, freq=freq_str, inclusive="both"
208
+ )
209
+
210
+ periods = []
211
+ for i, timestamp in enumerate(timestamps):
212
+ # Convert to Unix timestamp (seconds)
213
+ unix_timestamp = int(timestamp.timestamp())
214
+
215
+ # Create formatted time string (UTC to avoid DST issues)
216
+ formatted_time = timestamp.strftime("%Y-%m-%d %H:%M:%S")
217
+
218
+ periods.append(
219
+ TimePeriod(
220
+ timestamp=unix_timestamp,
221
+ period_index=i,
222
+ formatted_time=formatted_time,
223
+ )
224
+ )
225
+
226
+ logger.info(
227
+ f"Generated {len(periods)} time periods from {start} to {end} "
228
+ f"at {resolution_minutes}min resolution"
229
+ )
230
+ return periods
231
+
232
+ def _copy_database_structure(
233
+ self,
234
+ source_path: str,
235
+ target_path: str,
236
+ new_periods: List[TimePeriod],
237
+ new_resolution_minutes: int,
238
+ new_network_name: Optional[str],
239
+ progress_callback: Callable[[float, str], None],
240
+ ):
241
+ """Copy database schema and static data, update time periods."""
242
+
243
+ # Copy entire database file as starting point
244
+ progress_callback(25, "Copying database file...")
245
+ shutil.copy2(source_path, target_path)
246
+
247
+ # Connect to target database and update time periods
248
+ progress_callback(30, "Updating time periods...")
249
+
250
+ with database_context(target_path) as target_conn:
251
+ # Clear existing time periods (single row for entire database)
252
+ target_conn.execute("DELETE FROM network_time_periods")
253
+
254
+ # Insert new optimized time periods metadata
255
+ if new_periods:
256
+ period_count = len(new_periods)
257
+ start_timestamp = new_periods[0].timestamp
258
+
259
+ # Calculate interval in seconds
260
+ if len(new_periods) > 1:
261
+ interval_seconds = (
262
+ new_periods[1].timestamp - new_periods[0].timestamp
263
+ )
264
+ else:
265
+ interval_seconds = new_resolution_minutes * 60
266
+
267
+ target_conn.execute(
268
+ """
269
+ INSERT INTO network_time_periods (period_count, start_timestamp, interval_seconds)
270
+ VALUES (?, ?, ?)
271
+ """,
272
+ (period_count, start_timestamp, interval_seconds),
273
+ )
274
+
275
+ # Update network metadata with new time range and resolution
276
+ start_time = new_periods[0].formatted_time if new_periods else None
277
+ end_time = new_periods[-1].formatted_time if new_periods else None
278
+
279
+ # Convert resolution to ISO 8601 duration format
280
+ if new_resolution_minutes < 60:
281
+ time_interval = f"PT{new_resolution_minutes}M"
282
+ elif new_resolution_minutes % 60 == 0:
283
+ hours = new_resolution_minutes // 60
284
+ time_interval = f"PT{hours}H"
285
+ else:
286
+ time_interval = f"PT{new_resolution_minutes}M"
287
+
288
+ # Update network metadata including name if provided
289
+ if new_network_name:
290
+ target_conn.execute(
291
+ """
292
+ UPDATE network_metadata
293
+ SET name = ?, time_start = ?, time_end = ?, time_interval = ?, updated_at = datetime('now')
294
+ """,
295
+ (new_network_name, start_time, end_time, time_interval),
296
+ )
297
+ else:
298
+ target_conn.execute(
299
+ """
300
+ UPDATE network_metadata
301
+ SET time_start = ?, time_end = ?, time_interval = ?, updated_at = datetime('now')
302
+ """,
303
+ (start_time, end_time, time_interval),
304
+ )
305
+
306
+ # Clear data that becomes invalid with new time axis
307
+ progress_callback(32, "Clearing time-dependent data...")
308
+
309
+ # Clear solve results (they're tied to specific time periods)
310
+ target_conn.execute("DELETE FROM network_solve_results")
311
+
312
+ # Clear year-based solve results (also tied to specific time periods)
313
+ target_conn.execute("DELETE FROM network_solve_results_by_year")
314
+
315
+ # Clear any cached data in network_data_store that might be time-dependent
316
+ target_conn.execute(
317
+ """
318
+ DELETE FROM network_data_store
319
+ WHERE category IN ('results', 'statistics', 'cache')
320
+ """
321
+ )
322
+
323
+ target_conn.commit()
324
+ progress_callback(35, f"Updated time periods: {len(new_periods)} periods")
325
+
326
+ def _process_all_timeseries(
327
+ self,
328
+ source_path: str,
329
+ target_path: str,
330
+ new_periods: List[TimePeriod],
331
+ new_resolution_minutes: int,
332
+ progress_callback: Callable[[float, str], None],
333
+ ) -> Dict[str, Any]:
334
+ """Process all timeseries attributes across all scenarios."""
335
+
336
+ stats = {
337
+ "total_components_processed": 0,
338
+ "total_attributes_processed": 0,
339
+ "total_scenarios_processed": 0,
340
+ "attributes_by_component_type": {},
341
+ "errors": [],
342
+ }
343
+
344
+ try:
345
+ # Find all components with timeseries data
346
+ components_with_timeseries = self._find_components_with_timeseries(
347
+ source_path
348
+ )
349
+
350
+ total_items = len(components_with_timeseries)
351
+ progress_callback(
352
+ 45, f"Found {total_items} timeseries attributes to process"
353
+ )
354
+
355
+ if total_items == 0:
356
+ progress_callback(90, "No timeseries data found to process")
357
+ return stats
358
+
359
+ # Group by scenario for batch processing efficiency
360
+ by_scenario: Dict[Optional[int], List[Tuple[int, str]]] = {}
361
+ for comp_id, attr_name, scenario_id in components_with_timeseries:
362
+ if scenario_id not in by_scenario:
363
+ by_scenario[scenario_id] = []
364
+ by_scenario[scenario_id].append((comp_id, attr_name))
365
+
366
+ stats["total_scenarios_processed"] = len(by_scenario)
367
+ logger.info(
368
+ f"Processing timeseries across {len(by_scenario)} scenarios: "
369
+ f"{list(by_scenario.keys())}"
370
+ )
371
+
372
+ # Process each scenario
373
+ processed = 0
374
+ for scenario_id, items in by_scenario.items():
375
+ scenario_name = f"scenario_{scenario_id}" if scenario_id else "base"
376
+ progress_callback(
377
+ 45 + (processed * 40 / total_items),
378
+ f"Processing scenario {scenario_name} ({len(items)} attributes)",
379
+ )
380
+
381
+ for comp_id, attr_name in items:
382
+ try:
383
+ # Get component type for statistics
384
+ comp_type = self._get_component_type(source_path, comp_id)
385
+ if comp_type not in stats["attributes_by_component_type"]:
386
+ stats["attributes_by_component_type"][comp_type] = 0
387
+
388
+ # Load original timeseries using pyconvexity API
389
+ original_timeseries = get_timeseries(
390
+ source_path, comp_id, attr_name, scenario_id
391
+ )
392
+
393
+ if not original_timeseries or not original_timeseries.values:
394
+ logger.warning(
395
+ f"No timeseries data found for component {comp_id}, "
396
+ f"attribute {attr_name}"
397
+ )
398
+ continue
399
+
400
+ # Get original time periods to understand the time mapping
401
+ with database_context(
402
+ source_path, read_only=True
403
+ ) as source_conn:
404
+ original_periods = get_network_time_periods(source_conn)
405
+
406
+ # Resample to new time axis with proper time-based slicing
407
+ resampled_values = self._resample_timeseries_with_time_mapping(
408
+ original_timeseries.values,
409
+ original_periods,
410
+ new_periods,
411
+ new_resolution_minutes,
412
+ )
413
+
414
+ if resampled_values:
415
+ # Save to target database using pyconvexity API
416
+ set_timeseries(
417
+ target_path,
418
+ comp_id,
419
+ attr_name,
420
+ resampled_values,
421
+ scenario_id,
422
+ )
423
+ stats["attributes_by_component_type"][comp_type] += 1
424
+ stats["total_attributes_processed"] += 1
425
+
426
+ processed += 1
427
+
428
+ if processed % 10 == 0: # Update progress every 10 items
429
+ progress = 45 + (processed * 40 / total_items)
430
+ progress_callback(
431
+ progress,
432
+ f"Processed {processed}/{total_items} attributes",
433
+ )
434
+
435
+ except Exception as e:
436
+ error_msg = (
437
+ f"Failed to process component {comp_id}, "
438
+ f"attribute {attr_name}: {str(e)}"
439
+ )
440
+ logger.error(error_msg)
441
+ stats["errors"].append(error_msg)
442
+ continue
443
+
444
+ # Count unique components processed
445
+ unique_components = set()
446
+ for comp_id, _, _ in components_with_timeseries:
447
+ unique_components.add(comp_id)
448
+ stats["total_components_processed"] = len(unique_components)
449
+
450
+ progress_callback(
451
+ 87,
452
+ f"Completed processing {stats['total_attributes_processed']} "
453
+ "timeseries attributes",
454
+ )
455
+
456
+ # VACUUM the database to reclaim space from replaced timeseries data
457
+ progress_callback(88, "Reclaiming database space...")
458
+ with database_context(target_path) as conn:
459
+ conn.execute("VACUUM")
460
+ progress_callback(
461
+ 90,
462
+ f"Database space reclaimed. Processed "
463
+ f"{stats['total_attributes_processed']} timeseries attributes.",
464
+ )
465
+
466
+ except Exception as e:
467
+ logger.error(f"Error processing timeseries data: {e}", exc_info=True)
468
+ stats["errors"].append(f"General processing error: {str(e)}")
469
+ raise
470
+
471
+ return stats
472
+
473
+ def _wipe_all_timeseries(
474
+ self, target_db_path: str, progress_callback: Callable[[float, str], None]
475
+ ) -> Dict[str, Any]:
476
+ """Wipes all timeseries attributes."""
477
+
478
+ with database_context(target_db_path) as target_conn:
479
+ try:
480
+ # Count timeseries attributes before deletion for statistics
481
+ cursor = target_conn.execute(
482
+ """
483
+ SELECT COUNT(*) FROM component_attributes
484
+ WHERE storage_type = 'timeseries'
485
+ """
486
+ )
487
+ count_before = cursor.fetchone()[0]
488
+
489
+ # Clear all timeseries attributes
490
+ target_conn.execute(
491
+ """
492
+ DELETE FROM component_attributes
493
+ WHERE storage_type = 'timeseries'
494
+ """
495
+ )
496
+
497
+ target_conn.commit()
498
+ progress_callback(
499
+ 85, f"Wiped {count_before} timeseries attributes from network."
500
+ )
501
+
502
+ # VACUUM the database to reclaim space and reduce file size
503
+ progress_callback(87, "Reclaiming database space...")
504
+ target_conn.execute("VACUUM")
505
+ progress_callback(
506
+ 90,
507
+ f"Database space reclaimed. Wiped {count_before} timeseries attributes.",
508
+ )
509
+
510
+ return {
511
+ "total_attributes_wiped": count_before,
512
+ "total_components_processed": 0,
513
+ "total_attributes_processed": 0,
514
+ "total_scenarios_processed": 0,
515
+ "attributes_by_component_type": {},
516
+ "errors": [],
517
+ }
518
+ except Exception as e:
519
+ logger.error(
520
+ f"Failed to wipe timeseries attributes: {e}", exc_info=True
521
+ )
522
+ return {
523
+ "total_attributes_wiped": 0,
524
+ "total_components_processed": 0,
525
+ "total_attributes_processed": 0,
526
+ "total_scenarios_processed": 0,
527
+ "attributes_by_component_type": {},
528
+ "errors": [f"Failed to wipe timeseries attributes: {str(e)}"],
529
+ }
530
+
531
+ def _find_components_with_timeseries(
532
+ self, db_path: str
533
+ ) -> List[Tuple[int, str, Optional[int]]]:
534
+ """Find all components that have timeseries attributes."""
535
+
536
+ with database_context(db_path, read_only=True) as conn:
537
+ cursor = conn.execute(
538
+ """
539
+ SELECT DISTINCT component_id, attribute_name, scenario_id
540
+ FROM component_attributes
541
+ WHERE storage_type = 'timeseries'
542
+ AND timeseries_data IS NOT NULL
543
+ ORDER BY component_id, attribute_name, scenario_id
544
+ """
545
+ )
546
+
547
+ results = cursor.fetchall()
548
+ logger.info(f"Found {len(results)} timeseries attributes in database")
549
+
550
+ return results
551
+
552
+ def _get_component_type(self, db_path: str, component_id: int) -> str:
553
+ """Get component type for statistics tracking."""
554
+ with database_context(db_path, read_only=True) as conn:
555
+ cursor = conn.execute(
556
+ "SELECT component_type FROM components WHERE id = ?", (component_id,)
557
+ )
558
+ row = cursor.fetchone()
559
+ return row[0] if row else "UNKNOWN"
560
+
561
+ def _resample_timeseries_with_time_mapping(
562
+ self,
563
+ original_values: List[float],
564
+ original_periods: List[TimePeriod],
565
+ new_periods: List[TimePeriod],
566
+ new_resolution_minutes: int,
567
+ ) -> List[float]:
568
+ """
569
+ Resample timeseries data to new time axis with proper time-based slicing.
570
+
571
+ This method:
572
+ 1. First slices the original data to match the new time range
573
+ 2. Then resamples the sliced data to the new resolution
574
+
575
+ Args:
576
+ original_values: Original timeseries values
577
+ original_periods: Original time periods from source database
578
+ new_periods: New time periods for target database
579
+ new_resolution_minutes: New time resolution in minutes
580
+
581
+ Returns:
582
+ Resampled values list, or empty list if resampling fails
583
+ """
584
+
585
+ if not original_values or not new_periods or not original_periods:
586
+ return []
587
+
588
+ try:
589
+ # Get time bounds for the new time axis
590
+ new_start_timestamp = new_periods[0].timestamp
591
+ new_end_timestamp = new_periods[-1].timestamp
592
+
593
+ logger.debug(
594
+ f"Original data: {len(original_values)} points, "
595
+ f"{len(original_periods)} periods"
596
+ )
597
+ logger.debug(
598
+ f"New time range: {new_periods[0].formatted_time} to "
599
+ f"{new_periods[-1].formatted_time}"
600
+ )
601
+
602
+ # Find the slice of original data that falls within the new time range
603
+ start_idx = 0
604
+ end_idx = len(original_periods)
605
+
606
+ # Find start index - first period >= new_start_timestamp
607
+ for i, period in enumerate(original_periods):
608
+ if period.timestamp >= new_start_timestamp:
609
+ start_idx = i
610
+ break
611
+
612
+ # Find end index - last period <= new_end_timestamp
613
+ for i in range(len(original_periods) - 1, -1, -1):
614
+ if original_periods[i].timestamp <= new_end_timestamp:
615
+ end_idx = i + 1 # +1 because slice end is exclusive
616
+ break
617
+
618
+ # Slice the original data to the new time range
619
+ if start_idx >= len(original_values):
620
+ logger.warning("Start index beyond original data range")
621
+ return []
622
+
623
+ end_idx = min(end_idx, len(original_values))
624
+ sliced_values = original_values[start_idx:end_idx]
625
+ sliced_periods = original_periods[start_idx:end_idx]
626
+
627
+ logger.debug(
628
+ f"Sliced data: {len(sliced_values)} points from index "
629
+ f"{start_idx} to {end_idx}"
630
+ )
631
+
632
+ if not sliced_values:
633
+ logger.warning("No data in the specified time range")
634
+ return []
635
+
636
+ # Now resample the sliced data to the new resolution
637
+ return self._resample_sliced_data(sliced_values, len(new_periods))
638
+
639
+ except Exception as e:
640
+ logger.error(
641
+ f"Failed to resample timeseries with time mapping: {e}", exc_info=True
642
+ )
643
+ # Return empty list rather than failing the entire operation
644
+ return []
645
+
646
+ def _resample_sliced_data(
647
+ self, sliced_values: List[float], target_length: int
648
+ ) -> List[float]:
649
+ """
650
+ Resample already time-sliced data to target length.
651
+
652
+ For downsampling (fewer periods): Use mean aggregation
653
+ For upsampling (more periods): Use interpolation
654
+ For same length: Return as-is
655
+ """
656
+
657
+ if not sliced_values:
658
+ return []
659
+
660
+ try:
661
+ original_length = len(sliced_values)
662
+
663
+ if original_length == target_length:
664
+ # Same length, return as-is
665
+ return sliced_values
666
+ elif original_length > target_length:
667
+ # Downsample using mean aggregation for better accuracy
668
+ return self._downsample_with_mean(sliced_values, target_length)
669
+ else:
670
+ # Upsample using linear interpolation
671
+ return self._upsample_with_interpolation(sliced_values, target_length)
672
+
673
+ except Exception as e:
674
+ logger.error(f"Failed to resample sliced data: {e}", exc_info=True)
675
+ return []
676
+
677
+ def _downsample_with_mean(
678
+ self, values: List[float], target_length: int
679
+ ) -> List[float]:
680
+ """Downsample using mean aggregation for better accuracy than simple sampling."""
681
+ if target_length >= len(values):
682
+ return values
683
+
684
+ # Calculate how many original points to average for each new point
685
+ chunk_size = len(values) / target_length
686
+ resampled = []
687
+
688
+ for i in range(target_length):
689
+ start_idx = int(i * chunk_size)
690
+ end_idx = int((i + 1) * chunk_size)
691
+
692
+ # Handle the last chunk to include any remaining values
693
+ if i == target_length - 1:
694
+ end_idx = len(values)
695
+
696
+ # Calculate mean of the chunk
697
+ chunk_values = values[start_idx:end_idx]
698
+ if chunk_values:
699
+ mean_value = sum(chunk_values) / len(chunk_values)
700
+ resampled.append(mean_value)
701
+ else:
702
+ # Fallback to last known value
703
+ resampled.append(values[start_idx] if start_idx < len(values) else 0.0)
704
+
705
+ return resampled
706
+
707
+ def _upsample_with_interpolation(
708
+ self, values: List[float], target_length: int
709
+ ) -> List[float]:
710
+ """Upsample using linear interpolation for smoother results."""
711
+ if target_length <= len(values):
712
+ return values[:target_length]
713
+
714
+ # Use numpy for efficient interpolation
715
+ original_indices = np.linspace(0, len(values) - 1, len(values))
716
+ target_indices = np.linspace(0, len(values) - 1, target_length)
717
+
718
+ # Perform linear interpolation
719
+ interpolated = np.interp(target_indices, original_indices, values)
720
+
721
+ return interpolated.tolist()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyconvexity
3
- Version: 0.5.0.post1
3
+ Version: 0.5.1
4
4
  Summary: Python library for energy system modeling and optimization with PyPSA
5
5
  Author-email: Convexity Team <info@convexity.com>
6
6
  License: MIT
@@ -43,6 +43,9 @@ src/pyconvexity/solvers/pypsa/clearing_price.py
43
43
  src/pyconvexity/solvers/pypsa/constraints.py
44
44
  src/pyconvexity/solvers/pypsa/solver.py
45
45
  src/pyconvexity/solvers/pypsa/storage.py
46
+ src/pyconvexity/transformations/__init__.py
47
+ src/pyconvexity/transformations/api.py
48
+ src/pyconvexity/transformations/time_axis.py
46
49
  src/pyconvexity/validation/__init__.py
47
50
  src/pyconvexity/validation/rules.py
48
51
  tests/test_core_types.py
@@ -1 +0,0 @@
1
- __version__ = "0.5.0.post1"
File without changes
File without changes