pyglenn 0.1.2__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.
pyglenn/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """
2
+ pyglenn — Thermochemical properties calculator.
3
+
4
+ Computes Cp(T), H°(T), S°(T) from NASA polynomial coefficients
5
+ stored in a SQLite database, converted from FORTRAN thermo.inp files.
6
+ """
7
+
8
+ __version__ = '0.1.2'
9
+ __author__ = 'Dr. Reginaldo G. Leão Jr.'
10
+
11
+ from .builder import ThermoDBBuilder
12
+ from .calculator import (
13
+ DatabaseNotConnectedError,
14
+ SpeciesNotFoundError,
15
+ TemperatureOutOfRangeError,
16
+ ThermoCalcError,
17
+ ThermochemicalCalculator,
18
+ )
19
+ from .database import R, ThermoDBQuery
20
+
21
+ __all__ = [
22
+ 'ThermochemicalCalculator',
23
+ 'ThermoDBQuery',
24
+ 'ThermoDBBuilder',
25
+ 'R',
26
+ 'ThermoCalcError',
27
+ 'DatabaseNotConnectedError',
28
+ 'SpeciesNotFoundError',
29
+ 'TemperatureOutOfRangeError',
30
+ ]
pyglenn/builder.py ADDED
@@ -0,0 +1,488 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Database builder: converts thermo.inp (NASA FORTRAN format) → SQLite3.
4
+
5
+ FORTRAN Record Structure (Appendix C):
6
+ RECORD 1 – Species identification
7
+ RECORD 2 – General information
8
+ RECORD 3 – Temperature interval definition
9
+ RECORD 4 – First 5 polynomial coefficients
10
+ RECORD 5 – Last 2 coefficients + integration constants
11
+
12
+ Records 3–5 repeat for each temperature interval.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import re
19
+ import sqlite3
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # Regex: match FORTRAN double-precision scientific notation (e.g. 1.234567890D+05)
26
+ _FORTRAN_D_RE = re.compile(r'\d\.\d{0,9}D[+\-]\d{1,2}', re.IGNORECASE)
27
+
28
+
29
+ class ThermoDBBuilder:
30
+ """Build a SQLite database from a thermo.inp file."""
31
+
32
+ def __init__(self, inp_file: str, db_file: str) -> None:
33
+ self.inp_file: Path = Path(inp_file)
34
+ self.db_file: Path = Path(db_file)
35
+ self.conn: sqlite3.Connection | None = None
36
+ self.cursor: sqlite3.Cursor | None = None
37
+
38
+ # ------------------------------------------------------------------
39
+ # Database lifecycle
40
+ # ------------------------------------------------------------------
41
+ def connect(self) -> None:
42
+ """Connect to (or create) the SQLite database."""
43
+ self.conn = sqlite3.connect(str(self.db_file))
44
+ self.cursor = self.conn.cursor()
45
+ self.cursor.execute('PRAGMA foreign_keys = ON')
46
+ self.cursor.execute('PRAGMA journal_mode = WAL')
47
+
48
+ def close(self) -> None:
49
+ """Close the database connection."""
50
+ if self.conn:
51
+ self.conn.commit()
52
+ self.conn.close()
53
+ self.conn = None
54
+ self.cursor = None
55
+
56
+ # ------------------------------------------------------------------
57
+ # Schema
58
+ # ------------------------------------------------------------------
59
+ def create_tables(self) -> None:
60
+ """Create the normalised table structure."""
61
+ assert self.cursor is not None, 'Database not connected'
62
+ self.cursor.execute("""
63
+ CREATE TABLE IF NOT EXISTS species (
64
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
65
+ name TEXT NOT NULL UNIQUE,
66
+ formula TEXT,
67
+ comments TEXT,
68
+ reference_code TEXT,
69
+ phase TEXT CHECK(phase IN ('gas', 'condensed')),
70
+ molecular_weight REAL,
71
+ heat_of_formation_298K REAL,
72
+ num_intervals INTEGER,
73
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
74
+ )
75
+ """)
76
+
77
+ self.cursor.execute("""
78
+ CREATE TABLE IF NOT EXISTS temperature_intervals (
79
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
80
+ species_id INTEGER NOT NULL,
81
+ interval_number INTEGER NOT NULL,
82
+ temp_min REAL NOT NULL,
83
+ temp_max REAL NOT NULL,
84
+ h_298_to_0 REAL,
85
+ FOREIGN KEY (species_id)
86
+ REFERENCES species(id) ON DELETE CASCADE,
87
+ UNIQUE(species_id, interval_number)
88
+ )
89
+ """)
90
+
91
+ self.cursor.execute("""
92
+ CREATE TABLE IF NOT EXISTS coefficients (
93
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ interval_id INTEGER NOT NULL UNIQUE,
95
+ a1 REAL, a2 REAL, a3 REAL, a4 REAL, a5 REAL,
96
+ a6 REAL, a7 REAL,
97
+ b1 REAL, b2 REAL,
98
+ FOREIGN KEY (interval_id)
99
+ REFERENCES temperature_intervals(id) ON DELETE CASCADE
100
+ )
101
+ """)
102
+
103
+ assert self.cursor is not None
104
+ self.cursor.execute("""
105
+ CREATE TABLE IF NOT EXISTS file_metadata (
106
+ id INTEGER PRIMARY KEY,
107
+ temp_min_global REAL,
108
+ temp_500_K REAL,
109
+ temp_1500_K REAL,
110
+ temp_max_global REAL,
111
+ reference_date TEXT,
112
+ total_species INTEGER
113
+ )
114
+ """)
115
+
116
+ assert self.conn is not None
117
+ self.conn.commit()
118
+
119
+ # ------------------------------------------------------------------
120
+ # Low-level parsers
121
+ # ------------------------------------------------------------------
122
+ @staticmethod
123
+ def parse_float(value: str) -> float | None:
124
+ """Parse a FORTRAN-style float ('D' → 'E').
125
+
126
+ Args:
127
+ value: String possibly in FORTRAN D notation.
128
+
129
+ Returns:
130
+ Float value or None if parsing fails.
131
+ """
132
+ if not value or not value.strip():
133
+ return None
134
+ try:
135
+ return float(value.strip().replace('D', 'E').replace('d', 'e'))
136
+ except ValueError:
137
+ return None
138
+
139
+ @staticmethod
140
+ def parse_species_record(line: str) -> tuple[str, str]:
141
+ """Extract species name (cols 1-16) and comments (cols 19-80).
142
+
143
+ Args:
144
+ line: RECORD 1 line from thermo.inp.
145
+
146
+ Returns:
147
+ Tuple of (species_name, comments).
148
+ """
149
+ name = line[0:16].strip() if len(line) > 16 else line.strip()
150
+ comments = line[18:80].strip() if len(line) > 18 else ''
151
+ return name, comments
152
+
153
+ def parse_general_info_record(self, line: str) -> dict[str, Any]:
154
+ """Parse RECORD 2 – general information.
155
+
156
+ Args:
157
+ line: RECORD 2 line from thermo.inp.
158
+
159
+ Returns:
160
+ Dict with num_intervals, ref_code, phase, molecular_weight,
161
+ heat_of_formation.
162
+ """
163
+ data: dict[str, Any] = {}
164
+
165
+ try:
166
+ num_int_str = line[0:2].strip() if len(line) > 2 else ''
167
+ data['num_intervals'] = int(num_int_str) if num_int_str.isdigit() else 0
168
+
169
+ data['ref_code'] = line[3:9].strip() if len(line) > 9 else ''
170
+
171
+ phase_code = line[50:52].strip() if len(line) > 52 else '0'
172
+ data['phase'] = 'condensed' if phase_code and phase_code != '0' else 'gas'
173
+
174
+ mw_str = line[52:65].strip() if len(line) > 65 else ''
175
+ data['molecular_weight'] = self.parse_float(mw_str)
176
+
177
+ hf_str = line[65:80].strip() if len(line) > 80 else ''
178
+ data['heat_of_formation'] = self.parse_float(hf_str)
179
+ except Exception as e:
180
+ logger.warning('Error parsing RECORD 2: %s', e)
181
+
182
+ return data
183
+
184
+ @staticmethod
185
+ def parse_temp_interval_record(line: str) -> dict[str, Any]:
186
+ """Parse RECORD 3 – temperature interval.
187
+
188
+ Args:
189
+ line: RECORD 3 line from thermo.inp.
190
+
191
+ Returns:
192
+ Dict with temp_min, temp_max, h_298_to_0.
193
+ """
194
+ data: dict[str, Any] = {}
195
+
196
+ temp_min_str = line[0:11].strip() if len(line) > 11 else ''
197
+ temp_max_str = line[11:22].strip() if len(line) > 22 else ''
198
+ h298_str = line[65:80].strip() if len(line) > 80 else ''
199
+
200
+ data['temp_min'] = ThermoDBBuilder.parse_float(temp_min_str)
201
+ data['temp_max'] = ThermoDBBuilder.parse_float(temp_max_str)
202
+ data['h_298_to_0'] = ThermoDBBuilder.parse_float(h298_str)
203
+
204
+ return data
205
+
206
+ @staticmethod
207
+ def parse_coefficients_record(lines: list[str]) -> dict[str, Any]:
208
+ """Parse RECORDS 4-5 – polynomial coefficients.
209
+
210
+ Args:
211
+ lines: Two lines containing a1-a7 and b1-b2.
212
+
213
+ Returns:
214
+ Dict with keys a1-a7, b1, b2.
215
+ """
216
+ coeffs: dict[str, Any] = {}
217
+
218
+ line4 = lines[0] if len(lines) > 0 else ''
219
+ coeffs['a1'] = ThermoDBBuilder.parse_float(line4[0:16])
220
+ coeffs['a2'] = ThermoDBBuilder.parse_float(line4[16:32])
221
+ coeffs['a3'] = ThermoDBBuilder.parse_float(line4[32:48])
222
+ coeffs['a4'] = ThermoDBBuilder.parse_float(line4[48:64])
223
+ coeffs['a5'] = ThermoDBBuilder.parse_float(line4[64:80])
224
+
225
+ line5 = lines[1] if len(lines) > 1 else ''
226
+ coeffs['a6'] = ThermoDBBuilder.parse_float(line5[0:16])
227
+ coeffs['a7'] = ThermoDBBuilder.parse_float(line5[16:32])
228
+ coeffs['b1'] = ThermoDBBuilder.parse_float(line5[48:64])
229
+ coeffs['b2'] = ThermoDBBuilder.parse_float(line5[64:80])
230
+
231
+ return coeffs
232
+
233
+ # ------------------------------------------------------------------
234
+ # File reading & line-type detection
235
+ # ------------------------------------------------------------------
236
+ def read_thermo_file(self) -> list[str]:
237
+ """Read thermo.inp, stripping comments and blank lines.
238
+
239
+ Returns:
240
+ List of non-empty, non-comment lines.
241
+ """
242
+ with open(self.inp_file, encoding='utf-8', errors='ignore') as f:
243
+ lines = f.readlines()
244
+
245
+ return [
246
+ line.rstrip('\n\r')
247
+ for line in lines
248
+ if line.strip() and not line.strip().startswith('!')
249
+ ]
250
+
251
+ @staticmethod
252
+ def is_temperature_line(line: str) -> bool:
253
+ """Detect RECORD 3 (temperature interval).
254
+
255
+ A temperature line has two valid floats in cols 0-11 and 11-22
256
+ where the first is strictly less than the second.
257
+
258
+ Args:
259
+ line: A line from thermo.inp.
260
+
261
+ Returns:
262
+ True if the line appears to be a temperature interval record.
263
+ """
264
+ if len(line) < 22:
265
+ return False
266
+ try:
267
+ t1 = ThermoDBBuilder.parse_float(line[0:11])
268
+ t2 = ThermoDBBuilder.parse_float(line[11:22])
269
+ return t1 is not None and t2 is not None and t1 < t2
270
+ except Exception:
271
+ return False
272
+
273
+ @staticmethod
274
+ def is_coefficient_line(line: str) -> bool:
275
+ """Detect coefficient lines containing FORTRAN D notation.
276
+
277
+ Uses regex to match the standard FORTRAN double-precision format
278
+ (e.g. ``1.23456789D+01``), which is more robust than substring
279
+ matching.
280
+
281
+ Args:
282
+ line: A line from thermo.inp.
283
+
284
+ Returns:
285
+ True if the line contains at least one FORTRAN D-format number.
286
+ """
287
+ return bool(_FORTRAN_D_RE.search(line))
288
+
289
+ # ------------------------------------------------------------------
290
+ # Main parse & load
291
+ # ------------------------------------------------------------------
292
+ def parse_and_load(self) -> None:
293
+ """Parse the thermo.inp file and populate the database."""
294
+ assert self.cursor is not None, 'Database not connected'
295
+ lines = self.read_thermo_file()
296
+
297
+ if not lines:
298
+ logger.warning('Empty thermo.inp file!')
299
+ return
300
+
301
+ # --- Global metadata (line index 1) ---
302
+ metadata_line = lines[1] if len(lines) > 1 else ''
303
+ parts = metadata_line.split()
304
+ if len(parts) >= 4:
305
+ self.cursor.execute(
306
+ """
307
+ INSERT INTO file_metadata
308
+ (id, temp_min_global, temp_500_K, temp_1500_K,
309
+ temp_max_global, reference_date)
310
+ VALUES (1, ?, ?, ?, ?, ?)
311
+ """,
312
+ (
313
+ self.parse_float(parts[0]),
314
+ self.parse_float(parts[1]),
315
+ self.parse_float(parts[2]),
316
+ self.parse_float(parts[3]),
317
+ parts[4] if len(parts) > 4 else None,
318
+ ),
319
+ )
320
+
321
+ # --- Species loop ---
322
+ i = 2
323
+ species_count = 0
324
+ skipped = 0
325
+
326
+ while i < len(lines):
327
+ try:
328
+ # RECORD 1 – species name
329
+ if i >= len(lines):
330
+ break
331
+
332
+ species_name, comments = self.parse_species_record(lines[i])
333
+
334
+ if (
335
+ not species_name
336
+ or len(species_name.split()) > 1
337
+ or self.is_temperature_line(lines[i])
338
+ ):
339
+ i += 1
340
+ skipped += 1
341
+ continue
342
+
343
+ logger.info('Processing species: %s', species_name)
344
+ i += 1
345
+
346
+ # RECORD 2 – general info
347
+ if i >= len(lines):
348
+ break
349
+ general_info = self.parse_general_info_record(lines[i])
350
+ i += 1
351
+
352
+ if general_info.get('num_intervals', 0) <= 0:
353
+ skipped += 1
354
+ continue
355
+
356
+ # Insert species
357
+ try:
358
+ self.cursor.execute(
359
+ """
360
+ INSERT INTO species
361
+ (name, comments, reference_code, phase,
362
+ molecular_weight, heat_of_formation_298K,
363
+ num_intervals)
364
+ VALUES (?, ?, ?, ?, ?, ?, ?)
365
+ """,
366
+ (
367
+ species_name,
368
+ comments,
369
+ general_info.get('ref_code'),
370
+ general_info.get('phase'),
371
+ general_info.get('molecular_weight'),
372
+ general_info.get('heat_of_formation'),
373
+ general_info.get('num_intervals'),
374
+ ),
375
+ )
376
+ species_id = self.cursor.lastrowid
377
+ species_count += 1
378
+ except sqlite3.IntegrityError:
379
+ self.cursor.execute(
380
+ 'SELECT id FROM species WHERE name = ?',
381
+ (species_name,),
382
+ )
383
+ result = self.cursor.fetchone()
384
+ if result:
385
+ species_id = result[0]
386
+ else:
387
+ skipped += 1
388
+ continue
389
+
390
+ # --- Temperature intervals ---
391
+ num_intervals = general_info.get('num_intervals', 0)
392
+ for interval_num in range(num_intervals):
393
+ if i >= len(lines):
394
+ break
395
+
396
+ if not self.is_temperature_line(lines[i]):
397
+ break
398
+
399
+ temp_interval = self.parse_temp_interval_record(lines[i])
400
+ i += 1
401
+
402
+ if i + 1 >= len(lines):
403
+ break
404
+
405
+ if not (
406
+ self.is_coefficient_line(lines[i])
407
+ and self.is_coefficient_line(lines[i + 1])
408
+ ):
409
+ break
410
+
411
+ coeffs = self.parse_coefficients_record([lines[i], lines[i + 1]])
412
+ i += 2
413
+
414
+ if (
415
+ temp_interval.get('temp_min') is None
416
+ or temp_interval.get('temp_max') is None
417
+ ):
418
+ continue
419
+
420
+ try:
421
+ self.cursor.execute(
422
+ """
423
+ INSERT INTO temperature_intervals
424
+ (species_id, interval_number, temp_min,
425
+ temp_max, h_298_to_0)
426
+ VALUES (?, ?, ?, ?, ?)
427
+ """,
428
+ (
429
+ species_id,
430
+ interval_num + 1,
431
+ temp_interval.get('temp_min'),
432
+ temp_interval.get('temp_max'),
433
+ temp_interval.get('h_298_to_0'),
434
+ ),
435
+ )
436
+ interval_id = self.cursor.lastrowid
437
+
438
+ self.cursor.execute(
439
+ """
440
+ INSERT INTO coefficients
441
+ (interval_id, a1, a2, a3, a4, a5,
442
+ a6, a7, b1, b2)
443
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
444
+ """,
445
+ (
446
+ interval_id,
447
+ coeffs.get('a1'),
448
+ coeffs.get('a2'),
449
+ coeffs.get('a3'),
450
+ coeffs.get('a4'),
451
+ coeffs.get('a5'),
452
+ coeffs.get('a6'),
453
+ coeffs.get('a7'),
454
+ coeffs.get('b1'),
455
+ coeffs.get('b2'),
456
+ ),
457
+ )
458
+
459
+ logger.debug(
460
+ ' Interval %d: %sK - %sK',
461
+ interval_num + 1,
462
+ temp_interval.get('temp_min'),
463
+ temp_interval.get('temp_max'),
464
+ )
465
+ except Exception as e:
466
+ logger.warning(
467
+ 'Error inserting interval %d: %s',
468
+ interval_num + 1,
469
+ e,
470
+ )
471
+
472
+ except Exception as e:
473
+ logger.warning('Error processing line %d: %s', i, e)
474
+ i += 1
475
+
476
+ # Final metadata update
477
+ assert self.cursor is not None
478
+ self.cursor.execute(
479
+ 'UPDATE file_metadata SET total_species = ? WHERE id = 1',
480
+ (species_count,),
481
+ )
482
+ assert self.conn is not None
483
+ self.conn.commit()
484
+
485
+ logger.info('=' * 70)
486
+ logger.info('Total species loaded: %d', species_count)
487
+ logger.info('Skipped lines: %d', skipped)
488
+ logger.info('Database: %s', self.db_file)