Skip to content

Commit dc239e3

Browse files
committed
Merge branch 'main' into feat/derivatives
2 parents 76f7dec + 3bbe516 commit dc239e3

File tree

7 files changed

+303
-28
lines changed

7 files changed

+303
-28
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,19 @@ All notable user-visible changes to this project are documented here.
44

55
## [Unreleased]
66

7+
### Changed
8+
9+
### Fixed
10+
11+
### Added
12+
13+
## [3.0.4] - 2026-02-27
14+
715
### Changed
816
- Command-line input parsing now accepts explicit `.inp` filenames (`#44`).
917
- Python RP-1311 sample scripts were moved into `source/bind/python/cea/samples/rp1311/` for clearer organization (`#47`).
18+
- Expanded `reac` input compatibility with CEA2-style forms, including case-insensitive keywords, exploded formulas with implicit coefficients, `den` density aliases, molecular-weight aliases, and stricter mixed mole/weight basis validation (`#48`).
19+
- User-specified reactant enthalpy input is now applied as a runtime override for reactant thermo initialization (including database species) (`#48`).
1020

1121
### Fixed
1222
- Fixed a crashing output case and restored missing output values (`#45`).
@@ -15,6 +25,7 @@ All notable user-visible changes to this project are documented here.
1525
### Added
1626
- Added missing Python test dependencies to improve out-of-the-box test runs (`#41`).
1727
- Added Fortran and Python regression tests covering `EqSolution` reuse and detonation/equilibrium convergence behavior (`#47`).
28+
- Added `reac` parser regression tests for custom species inputs, molecular-weight aliases, density aliases/default units, case-insensitive tokens, and implicit formula coefficients (`#48`).
1829

1930
## [3.0.3] - 2026-02-20
2031

CMakeLists.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
88
set(CMAKE_DISABLE_SOURCE_CHANGES ON)
99
set(CMAKE_DISABLE_IN_SOURCE_BUILD ON)
1010
project(CEA
11-
VERSION 3.0.3
11+
VERSION 3.0.4
1212
LANGUAGES Fortran
1313
)
1414

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
copyright = ''
2323
author = 'Mark Leader'
2424
version = '3.0'
25-
release = '3.0.3'
25+
release = '3.0.4'
2626

2727
# -- General configuration ---------------------------------------------------
2828
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration

source/bind/python/cea/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
__version__ = "3.0.3"
1+
__version__ = "3.0.4"
22

33
# initialize libcea, loading in the default data files
44
from cea.lib.libcea import init as libcea_init

source/input.f90

Lines changed: 164 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ function read_input(filename) result(problems)
158158
! Locals
159159
integer, parameter :: max_problems = 100
160160
integer :: fin, n, ierr
161+
type(ProblemDB), allocatable :: parsed_problems(:)
162+
character(512) :: line
161163

162164
call log_info('Parsing input file: '//trim(filename))
163165
open(newunit=fin, file=filename, &
@@ -166,29 +168,42 @@ function read_input(filename) result(problems)
166168

167169
n = 1
168170
do
171+
do
172+
read(fin, '(a)', iostat=ierr) line
173+
if (ierr /= 0) exit
174+
line = adjustl(line)
175+
if (.not. is_empty(line)) then
176+
backspace(fin)
177+
exit
178+
end if
179+
end do
180+
if (ierr /= 0) exit
181+
169182
call log_info('Parsing problem specification '//to_str(n))
170183
call assert(n <= max_problems, "Maximum problem count exceeded.")
171-
problems(n) = read_problem(fin, ierr)
184+
call read_problem(fin, problems(n), ierr)
172185
if (ierr /= 0) exit
173186
n = n+1
174187
end do
175188

176-
problems = problems(1:n-1)
189+
allocate(parsed_problems(n-1))
190+
parsed_problems = problems(1:n-1)
191+
call move_alloc(parsed_problems, problems)
177192
call log_info('Parsed '//to_str(n-1)//' problems from '//trim(filename))
178193

179194
close(fin)
180195

181196
return
182197
end function
183198

184-
function read_problem(fin, ierr) result(problem)
199+
subroutine read_problem(fin, problem, ierr)
185200
! Reads a complete ProblemDB from the input stream.
186201
! Aborts program if malformed input is discovered.
187202
! Returns ierr < 0 if no further problems in the stream.
188203

189204
integer, intent(in) :: fin
205+
type(ProblemDB), intent(out) :: problem
190206
integer, intent(out) :: ierr
191-
type(ProblemDB) :: problem
192207

193208
character(512) :: line
194209
character(:), allocatable :: buffer, dsname
@@ -264,7 +279,7 @@ function read_problem(fin, ierr) result(problem)
264279
end select
265280

266281
end do
267-
end function
282+
end subroutine
268283

269284
function parse_prob(buffer) result(prob)
270285
! Parse the prob dataset for a given problem specification
@@ -423,28 +438,42 @@ function parse_reac(buffer) result(reac)
423438

424439
type(string_scanner) :: scanner
425440
character(15) :: token
426-
integer :: ierr, n, capacity, i
427-
logical :: has_na, has_fuel_oxid
441+
character(2) :: token2
442+
character(:), allocatable :: token_lower
443+
integer :: ierr, n, capacity, i, len_tok
444+
logical :: has_na, has_fuel_oxid, has_mole_amount, has_weight_amount
445+
type(Schedule) :: amount_sched
428446

429447
scanner = string_scanner(replace_delimiters(buffer, replace_commas=.false.))
430448
token = scanner%read_word() ! Skip 'reac' keyword
431449

432450
n = 0
433451
capacity = 32
434452
allocate(reac(capacity))
453+
has_mole_amount = .false.
454+
has_weight_amount = .false.
435455
do
436456
token = scanner%read_word(ierr)
437457
if (ierr < 0) exit ! Buffer exhausted
458+
token_lower = to_lower(trim(token))
459+
len_tok = len_trim(token_lower)
460+
if (len_tok == 0) cycle
461+
token2 = ' '
462+
if (len_tok >= 2) then
463+
token2 = token_lower(:2)
464+
else
465+
token2(1:1) = token_lower(1:1)
466+
end if
438467

439468
! Start new reactant definition
440-
select case(token(:2))
469+
select case(token2)
441470
case ('fu','ox','na')
442471
n = n+1
443472
if (n > capacity) then
444473
reac = [reac, reac]
445474
capacity = size(reac)
446475
end if
447-
reac(n)%type = token(:2)
476+
reac(n)%type = token_lower(:2)
448477
reac(n)%name = scanner%read_word()
449478
call log_debug('Parsing parameters for reactant '//reac(n)%name)
450479
cycle
@@ -454,12 +483,32 @@ function parse_reac(buffer) result(reac)
454483
if (n == 0) then
455484
call abort('reac dataset missing reactant definition before token: '//token)
456485
end if
457-
select case(token(:1))
458-
case ('m','w'); reac(n)%amount = parse_schedule(scanner, token)
486+
if (is_molecular_weight_token(token_lower)) then
487+
reac(n)%molecular_weight = parse_molecular_weight(scanner, token)
488+
cycle
489+
end if
490+
if (is_formula_element_token(token)) then
491+
reac(n)%formula = parse_formula(scanner, token)
492+
cycle
493+
end if
494+
select case(token_lower(1:1))
495+
case ('m','w')
496+
amount_sched = parse_schedule(scanner, token_lower)
497+
reac(n)%amount = amount_sched
498+
if (amount_sched%name == 'mole_frac') has_mole_amount = .true.
499+
if (amount_sched%name == 'weight_frac') has_weight_amount = .true.
459500
case ('t'); reac(n)%temperature = parse_schedule(scanner, token)
460501
case ('h','u'); reac(n)%enthalpy = parse_schedule(scanner, token)
461-
case ('r'); reac(n)%density = parse_schedule(scanner, token)
462-
case ('A':'Z'); reac(n)%formula = parse_formula(scanner, token)
502+
case ('r')
503+
reac(n)%density = parse_schedule(scanner, token)
504+
if (len_trim(reac(n)%density%units) == 0) reac(n)%density%units = 'g/cc'
505+
case ('d')
506+
if (is_density_token(token)) then
507+
reac(n)%density = parse_schedule(scanner, token)
508+
if (len_trim(reac(n)%density%units) == 0) reac(n)%density%units = 'g/cc'
509+
else
510+
call abort('reac dataset contains unrecognized token: '//token)
511+
end if
463512
case default
464513
call abort('reac dataset contains unrecognized token: '//token)
465514
end select
@@ -481,10 +530,6 @@ function parse_reac(buffer) result(reac)
481530
if (reac(i)%type == 'na') has_na = .true.
482531
if (reac(i)%type == 'fu' .or. reac(i)%type == 'ox') has_fuel_oxid = .true.
483532

484-
if (reac(i)%type == 'na') then
485-
call assert(allocated(reac(i)%amount), 'reac dataset missing amount for reactant #'//to_str(i))
486-
end if
487-
488533
if (allocated(reac(i)%amount)) then
489534
call assert(size(reac(i)%amount%values) > 0, &
490535
'reac dataset missing amount values for reactant #'//to_str(i))
@@ -507,7 +552,88 @@ function parse_reac(buffer) result(reac)
507552
call abort("reac dataset cannot mix 'name' reactants with 'fuel'/'oxid' reactants. " // &
508553
"Use only name=... for all reactants, or only fuel=/oxid= for all reactants.")
509554
end if
555+
if (has_mole_amount .and. has_weight_amount) then
556+
call abort("reac dataset cannot mix mole-based and weight-based reactant amounts.")
557+
end if
558+
559+
end function
560+
561+
logical function is_density_token(token) result(tf)
562+
character(*), intent(in) :: token
563+
character(:), allocatable :: tok
564+
integer :: l
565+
566+
tok = to_lower(trim(token))
567+
l = len_trim(tok)
568+
if (l == 0) then
569+
tf = .false.
570+
else
571+
tf = (tok(:min(3, l)) == 'den')
572+
end if
573+
end function
574+
575+
logical function is_molecular_weight_token(token) result(tf)
576+
character(*), intent(in) :: token
577+
character(:), allocatable :: tok
578+
579+
tok = to_lower(trim(token))
580+
tf = (tok == 'wt/mol') .or. (tok == 'wt/mole') .or. (tok == 'molwt') .or. &
581+
(tok == 'mwt') .or. (tok == 'mw')
582+
end function
583+
584+
logical function is_formula_element_token(token) result(tf)
585+
character(*), intent(in) :: token
586+
character(:), allocatable :: tok
587+
integer :: l
588+
589+
tok = trim(token)
590+
l = len_trim(tok)
591+
if (l < 1 .or. l > 2) then
592+
tf = .false.
593+
return
594+
end if
595+
if (.not. (tok(1:1) >= 'A' .and. tok(1:1) <= 'Z')) then
596+
tf = .false.
597+
return
598+
end if
599+
if (l == 2) then
600+
tf = (tok(2:2) >= 'a' .and. tok(2:2) <= 'z')
601+
else
602+
tf = .true.
603+
end if
604+
end function
605+
606+
function parse_molecular_weight(scanner, token) result(mw)
607+
type(string_scanner), intent(inout) :: scanner
608+
character(*), intent(in) :: token
609+
real(dp) :: mw
610+
character(:), allocatable :: units
611+
integer :: ierr
612+
613+
mw = scanner%peek_real(ierr)
614+
if (ierr == 0) then
615+
mw = scanner%read_real(ierr)
616+
return
617+
end if
618+
619+
units = to_lower(scanner%read_word(ierr))
620+
if (ierr /= 0) then
621+
call abort('reac dataset missing molecular weight value after token: '//trim(token))
622+
end if
623+
624+
mw = scanner%read_real(ierr)
625+
if (ierr /= 0) then
626+
call abort('reac dataset missing molecular weight value after units token: '//trim(units))
627+
end if
510628

629+
select case(trim(units))
630+
case ('g/mol', 'g/mole', 'kg/kmol')
631+
continue
632+
case ('kg/mol', 'kg/mole')
633+
mw = mw*1.0d3
634+
case default
635+
call abort('reac dataset has unrecognized molecular weight units: '//trim(units))
636+
end select
511637
end function
512638

513639
function parse_species(buffer) result(species)
@@ -637,6 +763,7 @@ function parse_formula(scanner, token) result(f)
637763
type(Formula) :: f
638764
integer, parameter :: max_values = 16
639765
integer :: i, n, ierr
766+
real(dp) :: val
640767
character(:), allocatable :: word
641768

642769
allocate(f%elements(max_values))
@@ -647,7 +774,9 @@ function parse_formula(scanner, token) result(f)
647774
call abort('parse_formula: element symbol too long: '//trim(token))
648775
end if
649776
f%elements(n) = token
650-
f%coefficients(n) = scanner%read_real()
777+
f%coefficients(n) = 1.0d0
778+
val = scanner%peek_real(ierr)
779+
if (ierr == 0) f%coefficients(n) = scanner%read_real(ierr)
651780

652781
do i = 2,size(f%elements)
653782
word = scanner%peek_word(ierr)
@@ -659,7 +788,9 @@ function parse_formula(scanner, token) result(f)
659788
call abort('parse_formula: element symbol too long: '//trim(word))
660789
end if
661790
f%elements(i) = word
662-
f%coefficients(i) = scanner%read_real()
791+
f%coefficients(i) = 1.0d0
792+
val = scanner%peek_real(ierr)
793+
if (ierr == 0) f%coefficients(i) = scanner%read_real(ierr)
663794
n = i
664795
case default
665796
exit ! Start of new keyword
@@ -792,6 +923,20 @@ function is_empty(line) result(tf)
792923
startswith(line,'!')
793924
end function
794925

926+
function to_lower(txt) result(out)
927+
character(*), intent(in) :: txt
928+
character(:), allocatable :: out
929+
integer :: i, code
930+
931+
out = txt
932+
do i = 1, len(out)
933+
code = iachar(out(i:i))
934+
if (code >= iachar('A') .and. code <= iachar('Z')) then
935+
out(i:i) = achar(code + 32)
936+
end if
937+
end do
938+
end function
939+
795940
function is_keyword(line) result(tf)
796941
character(*), intent(in) :: line
797942
logical :: tf

0 commit comments

Comments
 (0)