psr-graf 2.3.0__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.
psr/graf/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .graf import * # noqa: F403
psr/graf/graf.py ADDED
@@ -0,0 +1,716 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import os
5
+ import struct
6
+ from collections.abc import Iterable, Iterator
7
+ from contextlib import contextmanager
8
+ from typing import Any, BinaryIO, TextIO
9
+
10
+ __all__ = [
11
+ "BaseBinReader",
12
+ "BinReader",
13
+ "CsvReader",
14
+ "GrafError",
15
+ "GrafIOError",
16
+ "__version__",
17
+ "load_as_dataframe",
18
+ "open_bin",
19
+ "open_csv",
20
+ "version",
21
+ ]
22
+
23
+ __version__ = "2.3.0"
24
+
25
+ # Number of bytes in a word (int32, float, ...)
26
+ _WORD = 4
27
+
28
+ # Check whether pandas' dataframe is available.
29
+ try:
30
+ import numpy as np
31
+ import pandas as pd
32
+
33
+ _HAS_PANDAS = True
34
+ except ImportError:
35
+ _HAS_PANDAS = False
36
+
37
+
38
+ def version() -> str:
39
+ return __version__
40
+
41
+
42
+ class GrafError(Exception):
43
+ pass
44
+
45
+
46
+ class GrafIOError(GrafError):
47
+ pass
48
+
49
+
50
+ class _GrafReaderBase:
51
+ BLOCK_TYPE_BLOCK = 0
52
+ BLOCK_TYPE_HOUR = 1
53
+ STAGE_TYPE_WEEKLY = 1
54
+ STAGE_TYPE_MONTHLY = 2
55
+ STAGE_TYPE_13MONTHLY = 6
56
+ STAGE_DESCRIPTION = {
57
+ STAGE_TYPE_WEEKLY: "weekly",
58
+ STAGE_TYPE_MONTHLY: "monthly",
59
+ STAGE_TYPE_13MONTHLY: "13 months",
60
+ }
61
+ BLOCK_DESCRIPTION = {BLOCK_TYPE_BLOCK: "block", BLOCK_TYPE_HOUR: "hour"}
62
+
63
+ def __init__(self) -> None:
64
+ # string encoding
65
+ self._encoding = "utf-8"
66
+ # file references
67
+ self._name = ""
68
+ # hdr file data
69
+ self._case_initial_stage = None
70
+ self._stages = None
71
+ self._min_stage = None
72
+ self._max_stage = None
73
+ self._scenarios = None
74
+ self._varies_by_scenario = None
75
+ self._varies_by_block = None
76
+ self._hour_or_block = None
77
+ self._stage_type = None
78
+ self._initial_year = None
79
+ self._units = None
80
+ self._agents = None
81
+
82
+ @property
83
+ def initial_stage(self) -> int:
84
+ return self._case_initial_stage
85
+
86
+ @property
87
+ def stages(self) -> int:
88
+ return self._stages
89
+
90
+ @property
91
+ def min_stage(self) -> int:
92
+ return self._min_stage
93
+
94
+ @property
95
+ def max_stage(self) -> int:
96
+ return self._max_stage
97
+
98
+ @property
99
+ def scenarios(self) -> int:
100
+ return self._scenarios
101
+
102
+ @property
103
+ def varies_by_scenario(self) -> int:
104
+ return self._varies_by_scenario
105
+
106
+ @property
107
+ def varies_by_block(self) -> int:
108
+ return self._varies_by_block
109
+
110
+ @property
111
+ def hour_or_block(self) -> int:
112
+ return self._hour_or_block
113
+
114
+ @property
115
+ def stage_type(self) -> int:
116
+ return self._stage_type
117
+
118
+ @property
119
+ def initial_year(self) -> int:
120
+ return self._initial_year
121
+
122
+ @property
123
+ def units(self) -> str:
124
+ return self._units
125
+
126
+ @property
127
+ def agents(self) -> tuple[str, ...]:
128
+ return self._agents
129
+
130
+ @property
131
+ def name(self) -> str:
132
+ return self._name
133
+
134
+ def open(self, file_path: str | os.PathLike[str], **kwargs: Any) -> None:
135
+ pass
136
+
137
+ def close(self) -> None:
138
+ pass
139
+
140
+ def _check_indexes(self, stage: int, scenario: int, block: int | None = None) -> None:
141
+ if not self._min_stage <= stage <= self._max_stage:
142
+ raise IndexError(f"Stage {stage} out of range ({self._min_stage}, {self._max_stage}).")
143
+
144
+ if not 1 <= scenario <= self._scenarios:
145
+ raise IndexError(f"Scenario {scenario} out of range ({self._scenarios}).")
146
+
147
+ if block is not None:
148
+ total_blocks = self.blocks(stage)
149
+ if not 1 <= block <= total_blocks:
150
+ raise IndexError(f"Block {block} out of range ({total_blocks} for stage {stage}).")
151
+
152
+ def blocks(self, stage: int) -> int:
153
+ raise NotImplementedError
154
+
155
+
156
+ class BaseBinReader(_GrafReaderBase):
157
+ """
158
+ SDDP binary data reader class.
159
+ """
160
+
161
+ def __init__(self) -> None:
162
+ super().__init__()
163
+ # file references
164
+ self.__hdr_file_path = ""
165
+ self.__bin_file_path = ""
166
+ self._bin_file_handler: BinaryIO | None = None
167
+ self.__single_bin_mode = False
168
+ self.__bin_offset = 0
169
+ # print hdr information
170
+ self._print_metadata = False
171
+ # hdr file data
172
+ self._bin_version = None
173
+ self._name_length = None
174
+ self._bin_offsets = None
175
+
176
+ def __del__(self) -> None:
177
+ if self._bin_file_handler is not None:
178
+ self.close()
179
+
180
+ @property
181
+ def bin_version(self) -> int:
182
+ return self._bin_version
183
+
184
+ @property
185
+ def name_length(self) -> int:
186
+ return self._name_length
187
+
188
+ def open(self, file_path: str | os.PathLike[str], **kwargs: Any) -> None:
189
+ """
190
+ Opens a single binary or HDR and BIN file pairs for reading,
191
+ when one of then is specified. If a file path without extension is
192
+ specified, tries to open a pair of .hdr and .bin file.
193
+ The BIN file stays open during BinReader lifetime.
194
+
195
+ Keyword arguments:
196
+ print_metadata -- Print files metadata to stdout (Default = False).
197
+ encoding -- encoding to decode strings in binary files
198
+ (Default = utf-8).
199
+
200
+ Non thread-safe method.
201
+ """
202
+ self._encoding = kwargs.get("encoding", "utf-8")
203
+ self._print_metadata = kwargs.get("print_metadata", False)
204
+
205
+ # file paths
206
+ file_path = os.fspath(file_path)
207
+ base_path, ext = os.path.splitext(file_path)
208
+ if ext.lower() in (".hdr", ".bin", ""):
209
+ self.__hdr_file_path = base_path + ".hdr"
210
+ self.__bin_file_path = base_path + ".bin"
211
+ else:
212
+ self.__hdr_file_path = file_path
213
+ self.__single_bin_mode = True
214
+ self._name = os.path.basename(base_path)
215
+
216
+ # Check files existence.
217
+ if not os.path.exists(self.__hdr_file_path):
218
+ if not self.__single_bin_mode:
219
+ error_msg = f"HDR file not found: {self.__hdr_file_path}"
220
+ else:
221
+ error_msg = f"File not found: {self.__hdr_file_path}"
222
+ raise FileNotFoundError(error_msg)
223
+
224
+ if not self.__single_bin_mode:
225
+ if not os.path.exists(self.__bin_file_path):
226
+ error_msg = f"BIN file not found: {self.__bin_file_path}"
227
+ raise FileNotFoundError(error_msg)
228
+
229
+ if not self.__single_bin_mode:
230
+ # read HDR
231
+ with open(self.__hdr_file_path, "rb") as hdr_file:
232
+ self.__read_hdr(hdr_file)
233
+
234
+ # read BIN and keep it open
235
+ self._bin_file_handler = open(self.__bin_file_path, "rb")
236
+ else:
237
+ # Read single binary file and keep it open.
238
+ data_file = open(self.__hdr_file_path, "rb")
239
+ try:
240
+ self.__read_hdr(data_file)
241
+ except BaseException:
242
+ data_file.close()
243
+ raise
244
+ self.__bin_offset = data_file.tell()
245
+ self._bin_file_handler = data_file
246
+
247
+ def close(self) -> None:
248
+ """Closes the binary file for reading."""
249
+ if self._bin_file_handler is not None and not self._bin_file_handler.closed:
250
+ self._bin_file_handler.close()
251
+
252
+ def __read_hdr(self, input_stream: BinaryIO) -> None:
253
+ def unpack_int() -> int:
254
+ """Unpack 4 bytes as integer from input_stream and move its
255
+ position by 4 bytes.
256
+ """
257
+ return struct.unpack("<i", input_stream.read(_WORD))[0]
258
+
259
+ def unpack_str(length: int) -> str:
260
+ # Unpack variable length string.
261
+ bytes_value = struct.unpack(f"{length}s", input_stream.read(length))[0]
262
+ return bytes_value.decode(self._encoding).strip()
263
+
264
+ # Record #1
265
+ seek_curpos = 1 # Seek from current position flag
266
+ input_stream.seek(_WORD, seek_curpos)
267
+ self._bin_version = unpack_int()
268
+ input_stream.seek(_WORD, seek_curpos)
269
+
270
+ # Record #2
271
+ input_stream.seek(_WORD, seek_curpos)
272
+ self._min_stage = unpack_int()
273
+ self._max_stage = unpack_int()
274
+ self._scenarios = unpack_int()
275
+ agents_count = unpack_int()
276
+ self._varies_by_scenario = unpack_int()
277
+ self._varies_by_block = unpack_int()
278
+ self._hour_or_block = unpack_int()
279
+ self._stage_type = unpack_int()
280
+ self._case_initial_stage = unpack_int()
281
+ self._initial_year = unpack_int()
282
+ self._units = unpack_str(7)
283
+ self._name_length = unpack_int()
284
+ self._stages = self._max_stage - self._min_stage + 1
285
+
286
+ if self._varies_by_scenario == 0:
287
+ self._scenarios = 1
288
+
289
+ if self._print_metadata:
290
+ if not self.__single_bin_mode:
291
+ print("HDR/BIN graf metadata:")
292
+ else:
293
+ print("graf metadata:")
294
+ print(" Binary file version:", self._bin_version)
295
+ print(" First stage:", self._min_stage)
296
+ print(" Number of stages:", self._stages)
297
+ print(" Number of scenarios:", self._scenarios)
298
+ print(" Number of agents:", agents_count)
299
+ print(" Varies per scenario:", self._varies_by_scenario)
300
+ print(" Varies per block/hour:", self._varies_by_block)
301
+ print(" Time mapping:", BinReader.BLOCK_DESCRIPTION[self._hour_or_block])
302
+ print(" Type of stage:", BinReader.STAGE_DESCRIPTION[self._stage_type])
303
+ print(" Case Initial month/week:", self._case_initial_stage)
304
+ print(" Case Initial year:", self._initial_year)
305
+ print(" Units:", self._units)
306
+ print(" Stored name's length:", self._name_length)
307
+
308
+ input_stream.seek(_WORD, seek_curpos)
309
+
310
+ # Record #3
311
+ input_stream.seek(_WORD, seek_curpos)
312
+
313
+ self._bin_offsets = [unpack_int() for _ in range(self._stages + 1)]
314
+
315
+ input_stream.seek(_WORD, seek_curpos)
316
+
317
+ # Agent names
318
+ _agents = []
319
+ for _ in range(agents_count):
320
+ string_length = unpack_int()
321
+ _agents.append(unpack_str(string_length))
322
+ # discard unused bytes
323
+ input_stream.read(_WORD)
324
+ self._agents = tuple(_agents)
325
+
326
+ def _seek(self, i_stage: int, i_scenario: int, i_block: int) -> None:
327
+ # i_scenario, i_block are 1-based indexes; i_stage is 0-based.
328
+ index = (
329
+ self._bin_offsets[i_stage] * self._scenarios
330
+ + self.blocks(i_stage + self._min_stage) * (i_scenario - 1)
331
+ + (i_block - 1)
332
+ ) * len(self._agents)
333
+
334
+ offset_from_start = self.__bin_offset + index * _WORD
335
+ seek_from_start = 0
336
+ self._bin_file_handler.seek(offset_from_start, seek_from_start)
337
+
338
+ def _read_stage(
339
+ self, stage: int, first_scenario: int, last_scenario: int | None = None
340
+ ) -> tuple[int, bytes]:
341
+ """Read the raw values of all blocks of a given stage, from first_scenario
342
+ to last_scenario (Default = first_scenario). Returns the number of blocks
343
+ and the values, scenario by scenario and block by block."""
344
+ if last_scenario is None:
345
+ last_scenario = first_scenario
346
+ self._check_indexes(stage, first_scenario)
347
+ self._check_indexes(stage, last_scenario)
348
+ self._seek(stage - self._min_stage, first_scenario, 1)
349
+ blocks = self.blocks(stage)
350
+ count = (last_scenario - first_scenario + 1) * blocks * len(self._agents)
351
+ return blocks, self._bin_file_handler.read(count * _WORD)
352
+
353
+ def blocks(self, stage: int) -> int:
354
+ """Number of blocks for a given stage. 1-based stage."""
355
+ istage = stage - self._min_stage + 1
356
+ if self._varies_by_block != 0:
357
+ return self._bin_offsets[istage] - self._bin_offsets[istage - 1]
358
+ return 1
359
+
360
+ def read(self, stage: int, scenario: int, block: int) -> tuple[float, ...]:
361
+ """
362
+ Read data of a given stage, scenario, and block. Returns a tuple with
363
+ values for each agent.
364
+
365
+ Raises IndexError if stage, scenario, or block is out of bounds.
366
+
367
+ Non thread-safe.
368
+ """
369
+ self._check_indexes(stage, scenario, block)
370
+ self._seek(stage - self._min_stage, scenario, block)
371
+ agents = len(self._agents)
372
+ return struct.unpack(f"<{agents}f", self._bin_file_handler.read(agents * _WORD))
373
+
374
+ def read_blocks(self, stage: int, scenario: int) -> list[list[float]]:
375
+ """
376
+ Read data of a given stage and scenario. Returns a list
377
+ containing lists with block data, for each agent.
378
+
379
+ Raises IndexError if stage or scenario is out of bounds.
380
+
381
+ Non thread-safe.
382
+ """
383
+ blocks, buffer = self._read_stage(stage, scenario)
384
+ agents = len(self._agents)
385
+ all_values = struct.unpack(f"<{blocks * agents}f", buffer)
386
+ return [list(all_values[i_agent::agents]) for i_agent in range(agents)]
387
+
388
+ def read_blocks_as_array(self, stage: int, scenario: int) -> tuple[tuple[float, ...], ...]:
389
+ """
390
+ Read data of a given stage and scenario. Returns a tuple with
391
+ dimensions (blocks, agents).
392
+
393
+ Raises IndexError if stage or scenario is out of bounds.
394
+
395
+ Non thread-safe.
396
+ """
397
+ blocks, buffer = self._read_stage(stage, scenario)
398
+ agents = len(self._agents)
399
+ all_values = struct.unpack(f"<{blocks * agents}f", buffer)
400
+ return tuple(all_values[i : i + agents] for i in range(0, len(all_values), agents))
401
+
402
+
403
+ if not _HAS_PANDAS:
404
+
405
+ class BinReader(BaseBinReader):
406
+ pass
407
+
408
+ else:
409
+
410
+ class BinReader(BaseBinReader):
411
+ """
412
+ A modified version of the psr.graf.BinReader class with an alternative
413
+ to the read_blocks method, that avoids some expensive for loops by
414
+ using numpy to reshape the incoming stream into a 2D array.
415
+ """
416
+
417
+ def read_blocks_as_array(self, stage: int, scenario: int) -> np.ndarray:
418
+ """
419
+ Read data of a given stage and scenario. Returns a 2D numpy array
420
+ with dimensions (blocks, agents).
421
+
422
+ Raises IndexError if stage or scenario is out of bounds.
423
+
424
+ Author: corypdavis
425
+ """
426
+ blocks, buffer = self._read_stage(stage, scenario)
427
+ values = np.frombuffer(buffer, dtype="<f4").astype(np.float32)
428
+ return values.reshape((blocks, len(self._agents)))
429
+
430
+ def _read_stage_array(
431
+ self, stage: int, first_scenario: int, last_scenario: int
432
+ ) -> np.ndarray:
433
+ """Read the values of a range of scenarios of a given stage. Returns a
434
+ read-only numpy array with dimensions (scenarios, blocks, agents)."""
435
+ blocks, buffer = self._read_stage(stage, first_scenario, last_scenario)
436
+ values = np.frombuffer(buffer, dtype="<f4")
437
+ return values.reshape((-1, blocks, len(self._agents)))
438
+
439
+
440
+ class CsvReader(_GrafReaderBase):
441
+ def __init__(self) -> None:
442
+ super().__init__()
443
+ self.__csv_file_path = ""
444
+ self.__data = {}
445
+ self.__max_blocks_per_stage = {}
446
+
447
+ def open(self, file_path: str | os.PathLike[str], **kwargs: Any) -> None:
448
+ """
449
+ Opens a single csv file for reading.
450
+ """
451
+ self._encoding = kwargs.get("encoding", "utf-8")
452
+ file_path = os.fspath(file_path)
453
+ self.__csv_file_path = file_path
454
+ self._name = os.path.basename(self.__csv_file_path)
455
+
456
+ # Check file existence.
457
+ if not os.path.exists(file_path):
458
+ raise FileNotFoundError(f"CSV file not found: {file_path}")
459
+
460
+ with open(self.__csv_file_path, encoding=self._encoding) as csv_file:
461
+ self._read_header(csv_file)
462
+ self._read_data(csv_file)
463
+
464
+ def _read_header(self, csv_file: TextIO) -> None:
465
+ csv_reader = csv.reader(csv_file, delimiter=",", quotechar='"')
466
+ header_line1 = next(csv_reader)
467
+ header_line2 = next(csv_reader)
468
+ next(csv_reader)
469
+ header_line_4 = next(csv_reader)
470
+
471
+ self._varies_by_block = int(header_line1[1]) >= 1
472
+ self._units = header_line1[3].strip()
473
+ stage_type = int(header_line1[4])
474
+ self._case_initial_stage = int(header_line1[5])
475
+ self._initial_year = int(header_line1[6])
476
+ if stage_type in (
477
+ self.STAGE_TYPE_WEEKLY,
478
+ self.STAGE_TYPE_MONTHLY,
479
+ self.STAGE_TYPE_13MONTHLY,
480
+ ):
481
+ self._stage_type = stage_type
482
+ else:
483
+ raise ValueError(f"Invalid stage type: {stage_type}")
484
+
485
+ self._varies_by_scenario = int(header_line2[1]) == 1
486
+ self._agents = tuple(agent.strip() for agent in header_line_4[3:])
487
+ self._stages = 0
488
+ self._scenarios = 0
489
+
490
+ def _read_data(self, csv_file: TextIO) -> None:
491
+ csv_reader = csv.reader(csv_file, delimiter=",", quotechar='"')
492
+ self.__data = {}
493
+ self._max_stage = 0
494
+ self._min_stage = 999999
495
+ for line in csv_reader:
496
+ key = tuple(map(int, line[:3]))
497
+ values = tuple(map(float, line[3:]))
498
+ self.__data[key] = values
499
+ # Update limits
500
+ stage, scenario, block = key
501
+ self._max_stage = max(self._max_stage, stage)
502
+ self._min_stage = min(self._min_stage, stage)
503
+ self._scenarios = max(self._scenarios, scenario)
504
+ self.__max_blocks_per_stage[stage] = max(
505
+ self.__max_blocks_per_stage.get(stage, block), block
506
+ )
507
+
508
+ self._stages = self._max_stage - self._min_stage + 1
509
+
510
+ if self._is_hourly_data():
511
+ self._hour_or_block = self.BLOCK_TYPE_HOUR
512
+ else:
513
+ self._hour_or_block = self.BLOCK_TYPE_BLOCK
514
+
515
+ def _is_hourly_data(self) -> bool:
516
+ if self._stage_type == self.STAGE_TYPE_WEEKLY:
517
+ return all(blocks == 168 for blocks in self.__max_blocks_per_stage.values())
518
+ if self._stage_type == self.STAGE_TYPE_MONTHLY:
519
+ for stage, blocks in self.__max_blocks_per_stage.items():
520
+ if stage in (1, 3, 5, 7, 8, 10, 12):
521
+ if blocks != 744:
522
+ return False
523
+ elif stage in (4, 6, 9, 11):
524
+ if blocks != 720:
525
+ return False
526
+ else:
527
+ if blocks != 672:
528
+ return False
529
+ return True
530
+
531
+ return False
532
+
533
+ def blocks(self, stage: int) -> int:
534
+ """Number of blocks for a given stage. 1-based stage."""
535
+ if self._varies_by_block != 0:
536
+ return self.__max_blocks_per_stage[stage]
537
+ return 1
538
+
539
+ def read(self, stage: int, scenario: int, block: int) -> tuple[float, ...]:
540
+ """
541
+ Read data of a given stage, scenario, and block. Returns a tuple with
542
+ values for each agent.
543
+
544
+ Raises IndexError if stage, scenario, or block is out of bounds.
545
+
546
+ Non thread-safe.
547
+ """
548
+ self._check_indexes(stage, scenario, block)
549
+ return self.__data[(stage, scenario, block)]
550
+
551
+ def _read_stage_array(self, stage: int, first_scenario: int, last_scenario: int) -> np.ndarray:
552
+ """Read the values of a range of scenarios of a given stage. Returns a
553
+ numpy array with dimensions (scenarios, blocks, agents). Requires numpy."""
554
+ blocks = self.blocks(stage)
555
+ rows = [
556
+ self.read(stage, scenario, block)
557
+ for scenario in range(first_scenario, last_scenario + 1)
558
+ for block in range(1, blocks + 1)
559
+ ]
560
+ return np.array(rows, dtype=np.float64).reshape((-1, blocks, len(self._agents)))
561
+
562
+
563
+ @contextmanager
564
+ def open_bin(file_path: str | os.PathLike[str], **kwargs: Any) -> Iterator[BinReader]:
565
+ """
566
+ Open SDDP binary files (.hdr and .bin) for reading provided a file path
567
+ for one of them or a file path without extension. Yields a
568
+ BinReader object.
569
+
570
+ Keyword arguments:
571
+ print_metadata -- Print files metadata to stdout (Default = False).
572
+ encoding -- encoding to decode strings in binary files
573
+ (Default = utf-8).
574
+ """
575
+ obj = BinReader()
576
+ obj.open(file_path, **kwargs)
577
+ try:
578
+ yield obj
579
+ finally:
580
+ obj.close()
581
+
582
+
583
+ @contextmanager
584
+ def open_csv(file_path: str | os.PathLike[str], **kwargs: Any) -> Iterator[CsvReader]:
585
+ """
586
+ Open SDDP csv result files (.csv) for reading provided a file path.
587
+ Yields a CsvReader object.
588
+ """
589
+ obj = CsvReader()
590
+ obj.open(file_path, **kwargs)
591
+ try:
592
+ yield obj
593
+ finally:
594
+ obj.close()
595
+
596
+
597
+ def _get_agent_index_filter(
598
+ agents: tuple[str, ...], filter_agents: tuple[str, ...]
599
+ ) -> tuple[int, ...]:
600
+ """Indexes of filter_agents in agents. Names are case insensitive."""
601
+ if len(filter_agents) == 0:
602
+ return tuple(range(len(agents)))
603
+ lower_case_agents = tuple(agent.strip().lower() for agent in agents)
604
+ agents_index = []
605
+ for agent in filter_agents:
606
+ try:
607
+ agents_index.append(lower_case_agents.index(agent.strip().lower()))
608
+ except ValueError:
609
+ raise ValueError(f"Agent {agent!r} not found.") from None
610
+ return tuple(agents_index)
611
+
612
+
613
+ def load_as_dataframe(
614
+ file_path: str | os.PathLike[str],
615
+ *,
616
+ multi_index: bool = True,
617
+ index_format: str = "default",
618
+ filter_agents: Iterable[str] = (),
619
+ filter_stages: Iterable[int] = (),
620
+ filter_scenarios: Iterable[int] = (),
621
+ filter_blocks: Iterable[int] = (),
622
+ **kwargs: Any,
623
+ ) -> pd.DataFrame:
624
+ """
625
+ Load a SDDP result file (.hdr/.bin pair, single binary, or .csv) into a
626
+ pandas.DataFrame. Requires pandas.
627
+
628
+ Keyword arguments:
629
+ multi_index -- Use a pandas.MultiIndex for the index columns
630
+ (Default = True).
631
+ index_format -- 'default' (stage, scenario, block or hour) or 'period'
632
+ (year, month or week, scenario, block or hour).
633
+ filter_agents -- Agents to include, case insensitive (Default = all).
634
+ filter_stages -- Stages to include (Default = all).
635
+ filter_scenarios -- Scenarios to include (Default = all).
636
+ filter_blocks -- Blocks to include (Default = all).
637
+ Other keyword arguments, like encoding, are passed to open_bin or open_csv.
638
+ """
639
+ if not _HAS_PANDAS:
640
+ raise ImportError(
641
+ "load_as_dataframe requires pandas. Install it with: pip install psr-graf[pandas]"
642
+ )
643
+ if index_format not in ("default", "period"):
644
+ raise ValueError(f"Invalid index_format: {index_format!r}. Expected 'default' or 'period'.")
645
+
646
+ filter_agents = tuple(filter_agents)
647
+ filter_stages = set(filter_stages)
648
+ filter_scenarios = set(filter_scenarios)
649
+ filter_blocks = set(filter_blocks)
650
+
651
+ _, ext = os.path.splitext(file_path)
652
+ open_fn = open_csv if ext.lower() == ".csv" else open_bin
653
+ with open_fn(file_path, **kwargs) as graf_file:
654
+ agents_index = list(_get_agent_index_filter(graf_file.agents, filter_agents))
655
+ df_agents = [graf_file.agents[i] for i in agents_index]
656
+ scenarios = [
657
+ scenario
658
+ for scenario in range(1, graf_file.scenarios + 1)
659
+ if not filter_scenarios or scenario in filter_scenarios
660
+ ]
661
+
662
+ # Read each stage at once, then select its scenarios, blocks, and agents.
663
+ stage_columns, scenario_columns, block_columns, values = [], [], [], []
664
+ for stage in range(graf_file.min_stage, graf_file.max_stage + 1):
665
+ if filter_stages and stage not in filter_stages:
666
+ continue
667
+ blocks = [
668
+ block
669
+ for block in range(1, graf_file.blocks(stage) + 1)
670
+ if not filter_blocks or block in filter_blocks
671
+ ]
672
+ if not scenarios or not blocks:
673
+ continue
674
+ stage_values = graf_file._read_stage_array(stage, scenarios[0], scenarios[-1])
675
+ selection = np.ix_(
676
+ np.subtract(scenarios, scenarios[0]), np.subtract(blocks, 1), agents_index
677
+ )
678
+ values.append(stage_values[selection].reshape((-1, len(agents_index))))
679
+ stage_columns.append(np.full(len(scenarios) * len(blocks), stage, dtype=np.int64))
680
+ scenario_columns.append(np.repeat(np.array(scenarios, dtype=np.int64), len(blocks)))
681
+ block_columns.append(np.tile(np.array(blocks, dtype=np.int64), len(scenarios)))
682
+
683
+ if values:
684
+ values = np.concatenate(values).astype(np.float64, copy=False)
685
+ stage_column = np.concatenate(stage_columns)
686
+ scenario_column = np.concatenate(scenario_columns)
687
+ block_column = np.concatenate(block_columns)
688
+ else:
689
+ values = np.empty((0, len(agents_index)), dtype=np.float64)
690
+ stage_column = scenario_column = block_column = np.empty(0, dtype=np.int64)
691
+
692
+ block_or_hour = BinReader.BLOCK_DESCRIPTION[graf_file.hour_or_block]
693
+ if index_format == "default":
694
+ index_columns = ("stage", "scenario", block_or_hour)
695
+ index_arrays = [stage_column, scenario_column, block_column]
696
+ else:
697
+ if graf_file.stage_type == _GrafReaderBase.STAGE_TYPE_MONTHLY:
698
+ month_or_week = "month"
699
+ max_periods = 12
700
+ else:
701
+ month_or_week = "week"
702
+ max_periods = 52
703
+ period = stage_column + graf_file.initial_stage - 2
704
+ index_columns = ("year", month_or_week, "scenario", block_or_hour)
705
+ index_arrays = [
706
+ period // max_periods + graf_file.initial_year,
707
+ period % max_periods + 1,
708
+ scenario_column,
709
+ block_column,
710
+ ]
711
+
712
+ if multi_index:
713
+ index = pd.MultiIndex.from_arrays(index_arrays, names=index_columns)
714
+ return pd.DataFrame(values, index=index, columns=df_agents)
715
+ index_df = pd.DataFrame(dict(zip(index_columns, index_arrays, strict=True)))
716
+ return pd.concat([index_df, pd.DataFrame(values, columns=df_agents)], axis=1)
psr/graf/py.typed ADDED
File without changes
@@ -0,0 +1,336 @@
1
+ Metadata-Version: 2.5
2
+ Name: psr-graf
3
+ Version: 2.3.0
4
+ Summary: Utility module to read PSR SDDP's hdr/bin result file pairs.
5
+ Project-URL: Homepage, https://github.com/psrenergy/pygraf
6
+ Project-URL: Issues, https://github.com/psrenergy/pygraf/issues
7
+ Author-email: PSR <d.parrini@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3.15
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Provides-Extra: pandas
23
+ Requires-Dist: numpy; extra == 'pandas'
24
+ Requires-Dist: pandas; extra == 'pandas'
25
+ Description-Content-Type: text/markdown
26
+
27
+ PyGraf
28
+ ======
29
+
30
+
31
+ Utility module to read SDDP hdr/bin result file pairs. Some [examples](#usage-samples) to convert it to
32
+ popular formats are available.
33
+
34
+
35
+ Installing
36
+ ----------
37
+
38
+ Install the latest version from [PyPI](https://pypi.org/project/psr-graf/)
39
+
40
+ ```
41
+ pip install psr-graf
42
+ ```
43
+
44
+ To use `load_as_dataframe`, install it with the optional `pandas` dependency:
45
+
46
+ ```
47
+ pip install "psr-graf[pandas]"
48
+ ```
49
+
50
+ Or download this repository contents.
51
+
52
+
53
+ Usage
54
+ -----
55
+
56
+ Start by importing `psr.graf` module. It's possible to read data directly using `open_bin` and `open_csv` functions
57
+ or `load_as_dataframe` function if `pandas` package is available.
58
+
59
+
60
+ The example below shows how to load data directly into a `pandas.DataFrame` and prints the first 5 lines of data.
61
+
62
+ ```python
63
+ import psr.graf
64
+
65
+ df = psr.graf.load_as_dataframe("sample_data/gerter.hdr")
66
+ print(df.head())
67
+ ```
68
+
69
+ The output is:
70
+ ```
71
+ Thermal 1 Thermal 2 Thermal 3
72
+ stage scenario block
73
+ 1 1 1 7.440000 0.744 0.368069
74
+ 2 1 6.437624 0.000 0.000000
75
+ 3 1 7.440000 0.744 0.576140
76
+ 4 1 7.440000 0.744 2.994997
77
+ 5 1 7.440000 0.744 0.916644
78
+ ```
79
+
80
+ Alternatively, `open_bin` and `open_csv` functions can be used for direct data access as shown in the example next.
81
+
82
+ ```python
83
+ import psr.graf
84
+
85
+ with psr.graf.open_bin("sample_data/gerter.hdr") as graf_file:
86
+ print("Stages:", graf_file.stages)
87
+ print("Scenarios:", graf_file.scenarios)
88
+ print("Agents:", graf_file.agents)
89
+ print(f"Initial date: {graf_file.initial_year:04d}/{graf_file.initial_stage:02d}")
90
+ print("Units:", graf_file.units)
91
+ stage = 2
92
+ print(f"Number of blocks at stage {stage}: {graf_file.blocks(stage)}")
93
+ scenario = 10
94
+ block = 1
95
+ print(
96
+ f"Data at stage {stage}, scenario {scenario}, block {block}:",
97
+ graf_file.read(stage, scenario, block),
98
+ )
99
+ ```
100
+
101
+ The output is:
102
+ ```
103
+ Stages: 12
104
+ Scenarios: 50
105
+ Agents: ('Thermal 1', 'Thermal 2', 'Thermal 3')
106
+ Initial date: 2013/01
107
+ Units: GWh
108
+ Number of blocks at stage 2: 1
109
+ Data at stage 2, scenario 10, block 1: (7.440000057220459, 0.7440000176429749, 0.3680693209171295)
110
+ ```
111
+
112
+ Requirements
113
+ ------------
114
+
115
+ * Python 3.10 or newer.
116
+ * (Optional) `pandas` package to use `load_as_dataframe` function, installed with the `pandas` extra.
117
+
118
+
119
+ File Formats
120
+ ------------
121
+
122
+ | File Extension | Description |
123
+ |:--------------:|:---------------------------------|
124
+ | .hdr or .bin | Binary .hdr and .bin pair |
125
+ | .dat | Single-binary file |
126
+ | .csv | CSV file with specific structure |
127
+
128
+ * `load_as_dataframe` supports all of them and will determine which reader will be used based on the file extension.
129
+ * `open_bin` supports only .hdr/bin pairs or single-binary files.
130
+ * `open_csv` supports only CSV.
131
+
132
+
133
+ Both `open_bin`, `open_csv`, and `load_as_dataframe` functions accept `encoding` parameter to specify the encoding of the strings in file. The default is `utf-8`.
134
+
135
+
136
+ DataFrame options
137
+ -----------------
138
+
139
+
140
+ ### MultiIndex or single index
141
+
142
+ `load_as_dataframe` accepts an optional keyword argument `multi_index` (default `True`) to specify if the
143
+ returned `pandas.DataFrame` should use `pandas.MultiIndex` or not. If `False`, the returned `pandas.DataFrame` will have a
144
+ single automatic index and the columns 'stage', 'scenario', 'block' will appear before the agents' data.
145
+
146
+ Example:
147
+ ```python
148
+ import psr.graf
149
+
150
+ df = psr.graf.load_as_dataframe("sample_data/gerter.hdr", multi_index=False)
151
+ print(df.head())
152
+ print("Column names:", df.columns.values)
153
+ ```
154
+
155
+ The output is:
156
+ ```
157
+ stage scenario block Thermal 1 Thermal 2 Thermal 3
158
+ 0 1 1 1 7.440000 0.744 0.368069
159
+ 1 1 2 1 6.437624 0.000 0.000000
160
+ 2 1 3 1 7.440000 0.744 0.576140
161
+ 3 1 4 1 7.440000 0.744 2.994997
162
+ 4 1 5 1 7.440000 0.744 0.916644
163
+ Column names: ['stage' 'scenario' 'block' 'Thermal 1' 'Thermal 2' 'Thermal 3']
164
+ ```
165
+
166
+ On the other hand,
167
+
168
+ ```python
169
+ import psr.graf
170
+
171
+ df = psr.graf.load_as_dataframe("sample_data/gerter.hdr", multi_index=True)
172
+ print(df.head())
173
+ print("Column names:", df.columns.values)
174
+ ```
175
+
176
+ Will produce the following output:
177
+ ```
178
+ Thermal 1 Thermal 2 Thermal 3
179
+ stage scenario block
180
+ 1 1 1 7.440000 0.744 0.368069
181
+ 2 1 6.437624 0.000 0.000000
182
+ 3 1 7.440000 0.744 0.576140
183
+ 4 1 7.440000 0.744 2.994997
184
+ 5 1 7.440000 0.744 0.916644
185
+ Column names: ['Thermal 1' 'Thermal 2' 'Thermal 3']
186
+ ```
187
+
188
+ ### Index formats
189
+
190
+ The `index_format` specifies the format of index columns of the returned `pandas.Dataframe`. It accepts the following
191
+ values:
192
+
193
+ | Index Format | Columns |
194
+ |:------------:|:---------------------------------------------|
195
+ | `default` | stage, scenario, block or hour |
196
+ | `period` | year, month or week, scenario, block or hour |
197
+
198
+ * `default` creates a `pandas.DataFrame` with the columns as they are stored in the original file.
199
+ * `period` converts `stage` into `year`, `month` or `week` depending on the stage type of the file and the
200
+ initial year and stage.
201
+
202
+
203
+ ### Filter by stage, scenario, block and/or agent
204
+
205
+ The `load_as_dataframe` function accepts the following optional keyword arguments to filter data:
206
+
207
+ | Argument | Description |
208
+ |:------------------:|:------------------------------------------------------------------------------|
209
+ | `filter_stages` | List of stages to be included |
210
+ | `filter_blocks` | List of blocks to be included |
211
+ | `filter_scenarios` | List of scenarios to be included |
212
+ | `filter_agents` | List of agents to be included. The name is case insensitive. |
213
+
214
+ An empty list or unspecified means all stages, blocks, scenarios or agents will be included.
215
+
216
+ Example:
217
+ ```python
218
+ import psr.graf
219
+
220
+ df = psr.graf.load_as_dataframe(
221
+ "sample_data/gerter.hdr",
222
+ filter_stages=[
223
+ 8,
224
+ ],
225
+ filter_scenarios=[
226
+ 10,
227
+ ],
228
+ filter_agents=["Thermal 3", "Thermal 2"],
229
+ )
230
+ print(df.head())
231
+ ```
232
+
233
+ Outputs:
234
+ ```
235
+ Thermal 3 Thermal 2
236
+ stage scenario block
237
+ 8 10 1 8.030441 0.0
238
+ ```
239
+
240
+
241
+
242
+ Usage Samples
243
+ -------------
244
+
245
+ ### [`dataframes_sample.py`](https://github.com/psrenergy/pygraf/blob/main/dataframes_sample.py)
246
+
247
+ Shows how to read data into `pandas.DataFrame`s.
248
+
249
+ Requires `pandas` package installed.
250
+
251
+
252
+ ### [`matplotlib_sample.py`](https://github.com/psrenergy/pygraf/blob/main/matplotlib_sample.py)
253
+
254
+ Shows how to read data and plot data from hdr/bin file pairs.
255
+
256
+ ![](https://raw.githubusercontent.com/psrenergy/pygraf/main/docs/matplotlib_sample_plot.png)
257
+
258
+ Requires `matplotlib` package installed.
259
+
260
+
261
+
262
+ ### [`csv_sample.py`](https://github.com/psrenergy/pygraf/blob/main/csv_sample.py)
263
+
264
+ Shows how to convert from hdr/bin file pairs to csv using `psr.graf` module.
265
+
266
+ This script can also be called from command line:
267
+
268
+ ```bat
269
+ python csv_sample.py input_file.hdr output_file.csv
270
+ ```
271
+
272
+ Where `output_file.csv` is optional.
273
+
274
+
275
+ ### [`parquet_sample.py`](https://github.com/psrenergy/pygraf/blob/main/parquet_sample.py)
276
+
277
+ Shows how to convert from hdr/bin file pairs to Apache Parquet format.
278
+
279
+ Requires `pyarrow` package installed.
280
+
281
+ This script can also be called from command line:
282
+
283
+ ```bat
284
+ python parquet_sample.py input_file.hdr output_file.parquet
285
+ ```
286
+
287
+ Where `output_file.parquet` is optional.
288
+
289
+
290
+
291
+ Development
292
+ -----------
293
+
294
+ The project uses [uv](https://docs.astral.sh/uv/) for development. To set up an environment with the
295
+ development dependencies, run the tests, and lint and format the code:
296
+
297
+ ```
298
+ uv sync
299
+ uv run pytest
300
+ uv run ruff check
301
+ uv run ruff format
302
+ ```
303
+
304
+ To build the source distribution and wheel into `dist/`:
305
+
306
+ ```
307
+ uv build
308
+ ```
309
+
310
+
311
+ ### Releasing
312
+
313
+ Update `__version__` in `psr/graf/graf.py`, then push a tag with the same version:
314
+
315
+ ```
316
+ git tag 2.3.0
317
+ git push origin 2.3.0
318
+ ```
319
+
320
+ PyPI never accepts a version that was uploaded before, even after it is deleted, so versions like 2.2.0
321
+ cannot be used again.
322
+
323
+ The [Publish](.github/workflows/publish.yml) workflow runs the tests, builds the package, and
324
+ uploads it to PyPI using [trusted publishing](https://docs.pypi.org/trusted-publishers/).
325
+
326
+
327
+ Issues and Support
328
+ ------------------
329
+
330
+ Check PyGraf's [GitHub repository](https://github.com/psrenergy/pygraf/) and [issues page](https://github.com/psrenergy/pygraf/issues) for support.
331
+
332
+
333
+ License
334
+ -------
335
+
336
+ PyGraf is licensed under the [MIT License](https://github.com/psrenergy/pygraf/blob/main/LICENSE).
@@ -0,0 +1,7 @@
1
+ psr/graf/__init__.py,sha256=888LVNjnDSeRrCRyMmKzoUH5fdqCu7xPHjS-ev10vDE,34
2
+ psr/graf/graf.py,sha256=yii0D2DAG7hWvJx3lexJJZTw4TrQmsYmRcbXUCd2pqY,25788
3
+ psr/graf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ psr_graf-2.3.0.dist-info/METADATA,sha256=KvquCR1F5Ulp6dBG7Rw_3rkVq6x_uYbo9UDrvSYdzts,10012
5
+ psr_graf-2.3.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
6
+ psr_graf-2.3.0.dist-info/licenses/LICENSE,sha256=EEHshT2r876fXUsEDuEgtvH9bPs0PUbexFqsEujBLRM,1060
7
+ psr_graf-2.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 PSR
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.