multigrid-py 3.0.1__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.
- multigrid/__init__.py +30 -0
- multigrid/_warnings.py +13 -0
- multigrid/cli.py +72 -0
- multigrid/constants.py +20 -0
- multigrid/core.py +151 -0
- multigrid/grid_calculator.py +206 -0
- multigrid/grids.py +106 -0
- multigrid/hitran.py +91 -0
- multigrid/line_shapes.py +48 -0
- multigrid/molecules.py +11 -0
- multigrid/plotting.py +50 -0
- multigrid/profiles.py +24 -0
- multigrid/pt_table.py +42 -0
- multigrid/tips.py +45 -0
- multigrid/wing_corrections.py +28 -0
- multigrid_py-3.0.1.dist-info/LICENSE +21 -0
- multigrid_py-3.0.1.dist-info/METADATA +18 -0
- multigrid_py-3.0.1.dist-info/RECORD +21 -0
- multigrid_py-3.0.1.dist-info/WHEEL +5 -0
- multigrid_py-3.0.1.dist-info/entry_points.txt +3 -0
- multigrid_py-3.0.1.dist-info/top_level.txt +1 -0
multigrid/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""MARFA: Molecular atmospheric Absorption with Rapid and Flexible Analysis."""
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
# Library-friendly: don't configure the root logger; just swallow our own
|
|
5
|
+
# records if the user hasn't set up logging.
|
|
6
|
+
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
|
7
|
+
|
|
8
|
+
from .constants import Constants
|
|
9
|
+
from .molecules import MOLECULE_NAMES, MOLECULAR_MASSES
|
|
10
|
+
from .line_shapes import LineShapes
|
|
11
|
+
from .wing_corrections import WingCorrections
|
|
12
|
+
from .tips import TIPS
|
|
13
|
+
from .hitran import HITRANReader, SpectralLine
|
|
14
|
+
from .grids import GridState
|
|
15
|
+
from .grid_calculator import LineGridCalc
|
|
16
|
+
from .core import MARFA
|
|
17
|
+
from .profiles import AtmosphericProfile
|
|
18
|
+
from .pt_table import PTTableGenerator
|
|
19
|
+
from ._warnings import MarfaWarning, LineOutOfRangeWarning, InputFileWarning
|
|
20
|
+
|
|
21
|
+
__version__ = "3.0.1"
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Constants",
|
|
24
|
+
"MOLECULE_NAMES", "MOLECULAR_MASSES",
|
|
25
|
+
"LineShapes", "WingCorrections", "TIPS",
|
|
26
|
+
"HITRANReader", "SpectralLine",
|
|
27
|
+
"GridState", "LineGridCalc",
|
|
28
|
+
"MARFA", "AtmosphericProfile", "PTTableGenerator",
|
|
29
|
+
"MarfaWarning", "LineOutOfRangeWarning", "InputFileWarning",
|
|
30
|
+
]
|
multigrid/_warnings.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Custom warning classes emitted by MARFA."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class MarfaWarning(UserWarning):
|
|
5
|
+
"""Base class for all MARFA warnings."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LineOutOfRangeWarning(MarfaWarning):
|
|
9
|
+
"""Requested spectral range contains no lines from the database."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InputFileWarning(MarfaWarning):
|
|
13
|
+
"""A HITRAN input file is missing or empty."""
|
multigrid/cli.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Command-line interface for MARFA."""
|
|
2
|
+
import argparse
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from .core import MARFA
|
|
9
|
+
from .hitran import HITRANReader
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv=None) -> int:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="marfa",
|
|
15
|
+
description="MARFA: LBL molecular absorption calculator",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument("par_file", help="HITRAN .par file")
|
|
18
|
+
parser.add_argument("--mol", type=int, required=True,
|
|
19
|
+
help="HITRAN molecule ID (1-12)")
|
|
20
|
+
parser.add_argument("--nu-min", type=float, default=0.0)
|
|
21
|
+
parser.add_argument("--nu-max", type=float, default=4000.0)
|
|
22
|
+
parser.add_argument("--dnu", type=float, default=0.05)
|
|
23
|
+
parser.add_argument("--T", type=float, default=296.0)
|
|
24
|
+
parser.add_argument("--P", type=float, default=1.0)
|
|
25
|
+
parser.add_argument("--vmr", type=float, default=400e-6)
|
|
26
|
+
parser.add_argument("--cutoff", type=float, default=25.0)
|
|
27
|
+
parser.add_argument("--wing",
|
|
28
|
+
choices=["none", "tonkov", "perrin"],
|
|
29
|
+
default="none")
|
|
30
|
+
parser.add_argument("-o", "--output", default=None,
|
|
31
|
+
help="Save spectrum as .npz")
|
|
32
|
+
parser.add_argument("-v", "--verbose", action="store_true",
|
|
33
|
+
help="Enable debug logging")
|
|
34
|
+
args = parser.parse_args(argv)
|
|
35
|
+
|
|
36
|
+
logging.basicConfig(
|
|
37
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
38
|
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
lines = HITRANReader.read_par_file(
|
|
42
|
+
args.par_file,
|
|
43
|
+
molecule_id=args.mol,
|
|
44
|
+
wavenumber_min=args.nu_min,
|
|
45
|
+
wavenumber_max=args.nu_max,
|
|
46
|
+
)
|
|
47
|
+
if not lines:
|
|
48
|
+
print(f"No lines loaded from {args.par_file}", file=sys.stderr)
|
|
49
|
+
return 1
|
|
50
|
+
|
|
51
|
+
nu = np.arange(args.nu_min, args.nu_max + args.dnu, args.dnu)
|
|
52
|
+
marfa = MARFA()
|
|
53
|
+
|
|
54
|
+
print(f"Computing alpha(nu) over {len(nu)} points "
|
|
55
|
+
f"(T={args.T} K, P={args.P} atm, VMR={args.vmr:.3e}) ...")
|
|
56
|
+
alpha = marfa.calculate_absorption_coefficient(
|
|
57
|
+
nu, lines, args.T, args.P, args.vmr,
|
|
58
|
+
line_cutoff_cm=args.cutoff,
|
|
59
|
+
wing_correction=args.wing,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
print(f"Max alpha = {alpha.max():.4e} cm^-1")
|
|
63
|
+
|
|
64
|
+
if args.output:
|
|
65
|
+
np.savez(args.output, nu=nu, alpha=alpha)
|
|
66
|
+
print(f"Saved: {args.output}")
|
|
67
|
+
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
sys.exit(main())
|
multigrid/constants.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Physical constants matching Fortran MARFA."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Constants:
|
|
6
|
+
"""Physical constants - matching Fortran MARFA exactly."""
|
|
7
|
+
|
|
8
|
+
c = 2.99792458e10 # speed of light [cm/s]
|
|
9
|
+
h = 6.62606957e-27 # Planck constant [erg·s]
|
|
10
|
+
k_B = 1.380649e-16 # Boltzmann constant [erg/K]
|
|
11
|
+
k_B_SI = 1.380649e-23 # Boltzmann constant [J/K]
|
|
12
|
+
Na = 6.02214129e23 # Avogadro constant [1/mol]
|
|
13
|
+
R = 8.314472e7 # Gas constant [erg/(mol·K)]
|
|
14
|
+
c2 = 1.4388 # second radiation constant [cm·K]
|
|
15
|
+
T_ref = 296.0 # reference temperature [K]
|
|
16
|
+
P_ref = 1.0 # reference pressure [atm]
|
|
17
|
+
atm_to_pa = 101325.0 # 1 atm = 101325 Pa
|
|
18
|
+
sqrt_pi = np.sqrt(np.pi)
|
|
19
|
+
sqrt_ln2 = np.sqrt(np.log(2.0))
|
|
20
|
+
ln2_pi = np.log(2.0) / np.pi
|
multigrid/core.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Core MARFA calculation engine."""
|
|
2
|
+
import logging
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .constants import Constants
|
|
8
|
+
from .molecules import MOLECULAR_MASSES
|
|
9
|
+
from .tips import TIPS
|
|
10
|
+
from .line_shapes import LineShapes
|
|
11
|
+
from .wing_corrections import WingCorrections
|
|
12
|
+
from .hitran import SpectralLine
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MARFA:
|
|
18
|
+
"""
|
|
19
|
+
Main MARFA calculation engine.
|
|
20
|
+
|
|
21
|
+
Wraps the LBL engine with temperature-dependent intensities,
|
|
22
|
+
pressure broadening, and chi-factors.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, *, verbose: bool = True):
|
|
26
|
+
self.const = Constants()
|
|
27
|
+
self.tips = TIPS()
|
|
28
|
+
self.verbose = verbose
|
|
29
|
+
|
|
30
|
+
# ------------------------------------------------------------------
|
|
31
|
+
# line parameter helpers
|
|
32
|
+
# ------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def calculate_doppler_width(self, nu0: float, T: float, mol_id: int) -> float:
|
|
35
|
+
M = MOLECULAR_MASSES.get(mol_id, 44.0)
|
|
36
|
+
m = M / self.const.Na
|
|
37
|
+
return nu0 * np.sqrt(2.0 * self.const.k_B * T * np.log(2.0) /
|
|
38
|
+
(m * self.const.c ** 2))
|
|
39
|
+
|
|
40
|
+
def calculate_lorentz_width(self,
|
|
41
|
+
line: SpectralLine,
|
|
42
|
+
P: float,
|
|
43
|
+
P_self: float,
|
|
44
|
+
T: float) -> float:
|
|
45
|
+
pressure_term = line.gamma_air * (P - P_self) + line.gamma_self * P_self
|
|
46
|
+
temperature_factor = (self.const.T_ref / T) ** line.n_air
|
|
47
|
+
return pressure_term * temperature_factor
|
|
48
|
+
|
|
49
|
+
def calculate_line_intensity_at_T(self,
|
|
50
|
+
line: SpectralLine,
|
|
51
|
+
T: float,
|
|
52
|
+
P: float) -> float:
|
|
53
|
+
nu_shifted = line.nu + line.delta_air * P
|
|
54
|
+
Q_T = self.tips.get_Q(line.mol_id, T)
|
|
55
|
+
Q_ref = self.tips.get_Q(line.mol_id, self.const.T_ref)
|
|
56
|
+
partition_ratio = Q_ref / Q_T if Q_T > 0 else 1.0
|
|
57
|
+
|
|
58
|
+
boltzmann_ratio = (np.exp(-self.const.c2 * line.E_low / T) /
|
|
59
|
+
np.exp(-self.const.c2 * line.E_low / self.const.T_ref))
|
|
60
|
+
|
|
61
|
+
c2_nu_T = self.const.c2 * nu_shifted / T
|
|
62
|
+
c2_nu_r = self.const.c2 * nu_shifted / self.const.T_ref
|
|
63
|
+
emission_ratio = (1.0 if c2_nu_T > 50 else
|
|
64
|
+
(1.0 - np.exp(-c2_nu_T)) /
|
|
65
|
+
(1.0 - np.exp(-c2_nu_r)))
|
|
66
|
+
|
|
67
|
+
return line.S * partition_ratio * boltzmann_ratio * emission_ratio
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------
|
|
70
|
+
# absorption cross-section / coefficient
|
|
71
|
+
# ------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def calculate_absorption_cross_section(
|
|
74
|
+
self,
|
|
75
|
+
nu_grid: np.ndarray,
|
|
76
|
+
lines: List[SpectralLine],
|
|
77
|
+
T: float,
|
|
78
|
+
P: float,
|
|
79
|
+
self_broadening_fraction: float = 0.0,
|
|
80
|
+
line_cutoff_cm: float = 25.0,
|
|
81
|
+
wing_correction: str = "none",
|
|
82
|
+
) -> np.ndarray:
|
|
83
|
+
"""
|
|
84
|
+
Monochromatic absorption cross-section sigma(nu) [cm2/molecule].
|
|
85
|
+
|
|
86
|
+
Uses a direct line-by-line Voigt summation on ``nu_grid``. Integral
|
|
87
|
+
of sigma over all nu equals the line intensity S(T).
|
|
88
|
+
"""
|
|
89
|
+
if not lines:
|
|
90
|
+
return np.zeros_like(nu_grid)
|
|
91
|
+
|
|
92
|
+
sigma = np.zeros_like(nu_grid, dtype=float)
|
|
93
|
+
P_self = P * self_broadening_fraction
|
|
94
|
+
mol_id = lines[0].mol_id
|
|
95
|
+
|
|
96
|
+
chi_func = {
|
|
97
|
+
"tonkov": WingCorrections.tonkov_chi,
|
|
98
|
+
"perrin": WingCorrections.perrin_chi,
|
|
99
|
+
}.get(wing_correction, WingCorrections.no_correction)
|
|
100
|
+
|
|
101
|
+
for line in lines:
|
|
102
|
+
S_T = self.calculate_line_intensity_at_T(line, T, P)
|
|
103
|
+
if S_T <= 0.0:
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
gamma_D = self.calculate_doppler_width(line.nu, T, mol_id)
|
|
107
|
+
gamma_L = self.calculate_lorentz_width(line, P, P_self, T)
|
|
108
|
+
if max(gamma_D, gamma_L) <= 0.0:
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
mask = np.abs(nu_grid - line.nu) <= line_cutoff_cm
|
|
112
|
+
if not np.any(mask):
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
x = nu_grid[mask] - line.nu
|
|
116
|
+
profile = LineShapes.voigt_normalized(x, gamma_L, gamma_D)
|
|
117
|
+
|
|
118
|
+
if wing_correction != "none":
|
|
119
|
+
profile = profile * chi_func(x, line.nu)
|
|
120
|
+
|
|
121
|
+
sigma[mask] += S_T * profile
|
|
122
|
+
|
|
123
|
+
return sigma
|
|
124
|
+
|
|
125
|
+
def calculate_absorption_coefficient(
|
|
126
|
+
self,
|
|
127
|
+
nu_grid: np.ndarray,
|
|
128
|
+
lines: List[SpectralLine],
|
|
129
|
+
T: float,
|
|
130
|
+
P: float,
|
|
131
|
+
mole_fraction: float,
|
|
132
|
+
self_broadening_fraction: float = 0.0,
|
|
133
|
+
line_cutoff_cm: float = 25.0,
|
|
134
|
+
wing_correction: str = "none",
|
|
135
|
+
) -> np.ndarray:
|
|
136
|
+
"""
|
|
137
|
+
Monochromatic volume absorption coefficient alpha(nu) [cm-1].
|
|
138
|
+
|
|
139
|
+
alpha(nu) = sigma(nu) * n_species
|
|
140
|
+
where n_species = n_total * mole_fraction [molecules / cm3].
|
|
141
|
+
"""
|
|
142
|
+
sigma = self.calculate_absorption_cross_section(
|
|
143
|
+
nu_grid, lines, T, P,
|
|
144
|
+
self_broadening_fraction=self_broadening_fraction,
|
|
145
|
+
line_cutoff_cm=line_cutoff_cm,
|
|
146
|
+
wing_correction=wing_correction,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Total air number density [molecules / cm3]
|
|
150
|
+
n_total = (P * self.const.atm_to_pa) / (self.const.k_B_SI * T) / 1e6
|
|
151
|
+
return sigma * n_total * mole_fraction
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""LineGridCalc: Python port of Fortran LineGridCalc."""
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
from .grids import GridState
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LineGridCalc:
|
|
11
|
+
"""
|
|
12
|
+
Python port of the Fortran LineGridCalc module.
|
|
13
|
+
|
|
14
|
+
Implements ``leftLBL_full``, ``centerLBL_full`` and ``rightLBL_full``,
|
|
15
|
+
which route each spectral line's shape-function sample to the correct
|
|
16
|
+
multi-resolution stencil cell, matching the Fortran GOTO-cascade.
|
|
17
|
+
|
|
18
|
+
``FSHAPE`` plays the role of Fortran's ``procedure(shape)`` pointer: it
|
|
19
|
+
receives a single float offset and returns a float lineshape value.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, gs: GridState):
|
|
23
|
+
self.gs = gs
|
|
24
|
+
|
|
25
|
+
# ------------------------------------------------------------------
|
|
26
|
+
# leftLBL_full
|
|
27
|
+
# ------------------------------------------------------------------
|
|
28
|
+
def leftLBL_full(self,
|
|
29
|
+
FREQ: float,
|
|
30
|
+
UL: float,
|
|
31
|
+
FSHAPE: Callable[[float], float],
|
|
32
|
+
EPS: float) -> None:
|
|
33
|
+
gs = self.gs
|
|
34
|
+
UU = UL - FREQ
|
|
35
|
+
|
|
36
|
+
if UU >= 0.0:
|
|
37
|
+
return
|
|
38
|
+
if -UU > gs.cutOff:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
FF = float(FSHAPE(UU))
|
|
42
|
+
if FF < EPS:
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
gs.RK[1] += FF
|
|
46
|
+
|
|
47
|
+
if -UU < gs.H0:
|
|
48
|
+
XXX = gs.H0
|
|
49
|
+
for I in range(2, gs.NT0 + 1):
|
|
50
|
+
gs.RK0P[I] += FF
|
|
51
|
+
FF = float(FSHAPE(UU - XXX - gs.H1))
|
|
52
|
+
gs.RK0[I] += FF
|
|
53
|
+
XXX += gs.H0
|
|
54
|
+
FF = float(FSHAPE(UU - XXX))
|
|
55
|
+
gs.RK0L[I] += FF
|
|
56
|
+
if FF < EPS:
|
|
57
|
+
return
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
gs.RK0P[1] += FF
|
|
61
|
+
FF_c = float(FSHAPE(UU - gs.H1))
|
|
62
|
+
gs.RK0[1] += FF_c
|
|
63
|
+
FF = float(FSHAPE(UU - gs.H0))
|
|
64
|
+
gs.RK0L[1] += FF
|
|
65
|
+
|
|
66
|
+
if -UU < gs.H1:
|
|
67
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 1)
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
gs.RK1P[1] += FF
|
|
71
|
+
FF_c = float(FSHAPE(UU - gs.H2))
|
|
72
|
+
gs.RK1[1] += FF_c
|
|
73
|
+
FF = float(FSHAPE(UU - gs.H1))
|
|
74
|
+
gs.RK1L[1] += FF
|
|
75
|
+
if FF < EPS:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if -UU < gs.H2:
|
|
79
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 2)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
gs.RK2P[1] += FF
|
|
83
|
+
FF_c = float(FSHAPE(UU - gs.H3))
|
|
84
|
+
gs.RK2[1] += FF_c
|
|
85
|
+
FF = float(FSHAPE(UU - gs.H2))
|
|
86
|
+
gs.RK2L[1] += FF
|
|
87
|
+
if FF < EPS:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
if -UU < gs.H3:
|
|
91
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 3)
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
gs.RK3P[1] += FF
|
|
95
|
+
FF_c = float(FSHAPE(UU - gs.H4))
|
|
96
|
+
gs.RK3[1] += FF_c
|
|
97
|
+
FF = float(FSHAPE(UU - gs.H3))
|
|
98
|
+
gs.RK3L[1] += FF
|
|
99
|
+
if FF < EPS:
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
if -UU < gs.H4:
|
|
103
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 4)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
gs.RK4P[1] += FF
|
|
107
|
+
FF_c = float(FSHAPE(UU - gs.H5))
|
|
108
|
+
gs.RK4[1] += FF_c
|
|
109
|
+
FF = float(FSHAPE(UU - gs.H4))
|
|
110
|
+
gs.RK4L[1] += FF
|
|
111
|
+
if FF < EPS:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
if -UU < gs.H5:
|
|
115
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 5)
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
gs.RK5P[1] += FF
|
|
119
|
+
FF_c = float(FSHAPE(UU - gs.H6))
|
|
120
|
+
gs.RK5[1] += FF_c
|
|
121
|
+
FF = float(FSHAPE(UU - gs.H5))
|
|
122
|
+
gs.RK5L[1] += FF
|
|
123
|
+
if FF < EPS:
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if -UU < gs.H6:
|
|
127
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 6)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
gs.RK6P[1] += FF
|
|
131
|
+
FF_c = float(FSHAPE(UU - gs.H7))
|
|
132
|
+
gs.RK6[1] += FF_c
|
|
133
|
+
FF = float(FSHAPE(UU - gs.H6))
|
|
134
|
+
gs.RK6L[1] += FF
|
|
135
|
+
if FF < EPS:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
if -UU < gs.H7:
|
|
139
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 7)
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
gs.RK7P[1] += FF
|
|
143
|
+
FF_c = float(FSHAPE(UU - gs.H8))
|
|
144
|
+
gs.RK7[1] += FF_c
|
|
145
|
+
FF = float(FSHAPE(UU - gs.H7))
|
|
146
|
+
gs.RK7L[1] += FF
|
|
147
|
+
if FF < EPS:
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
if -UU < gs.H8:
|
|
151
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 8)
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
gs.RK8P[1] += FF
|
|
155
|
+
FF_c = float(FSHAPE(UU - gs.H9))
|
|
156
|
+
gs.RK8[1] += FF_c
|
|
157
|
+
FF = float(FSHAPE(UU - gs.H8))
|
|
158
|
+
gs.RK8L[1] += FF
|
|
159
|
+
if FF < EPS:
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
if -UU < gs.H9:
|
|
163
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 9)
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
gs.RK[2] += float(FSHAPE(UU - gs.H))
|
|
167
|
+
gs.RK[3] += float(FSHAPE(UU - gs.H - gs.H))
|
|
168
|
+
gs.RK[4] += float(FSHAPE(UU + gs.H - gs.H9))
|
|
169
|
+
FF = float(FSHAPE(UU - gs.H9))
|
|
170
|
+
gs.RK[5] += FF
|
|
171
|
+
|
|
172
|
+
self._rev_cascade_full(UU, FF, FSHAPE, EPS, 9, col=2)
|
|
173
|
+
|
|
174
|
+
def _rev_cascade_full(self,
|
|
175
|
+
UU: float,
|
|
176
|
+
FF: float,
|
|
177
|
+
FSHAPE: Callable[[float], float],
|
|
178
|
+
EPS: float,
|
|
179
|
+
start_level: int,
|
|
180
|
+
col: int = 2) -> None:
|
|
181
|
+
gs = self.gs
|
|
182
|
+
Hs = [gs.H0, gs.H1, gs.H2, gs.H3, gs.H4,
|
|
183
|
+
gs.H5, gs.H6, gs.H7, gs.H8, gs.H9]
|
|
184
|
+
Hs_n = [gs.H1, gs.H2, gs.H3, gs.H4, gs.H5,
|
|
185
|
+
gs.H6, gs.H7, gs.H8, gs.H9, gs.H + gs.H]
|
|
186
|
+
RKP = [gs.RK0P, gs.RK1P, gs.RK2P, gs.RK3P, gs.RK4P,
|
|
187
|
+
gs.RK5P, gs.RK6P, gs.RK7P, gs.RK8P, gs.RK9P]
|
|
188
|
+
RKC = [gs.RK0, gs.RK1, gs.RK2, gs.RK3, gs.RK4,
|
|
189
|
+
gs.RK5, gs.RK6, gs.RK7, gs.RK8, gs.RK9]
|
|
190
|
+
RKL = [gs.RK0L, gs.RK1L, gs.RK2L, gs.RK3L, gs.RK4L,
|
|
191
|
+
gs.RK5L, gs.RK6L, gs.RK7L, gs.RK8L, gs.RK9L]
|
|
192
|
+
|
|
193
|
+
for lvl in range(start_level - 1, -1, -1):
|
|
194
|
+
RKP[lvl][col] += FF
|
|
195
|
+
FF_c = float(FSHAPE(UU - Hs[lvl] - Hs_n[lvl]))
|
|
196
|
+
RKC[lvl][col] += FF_c
|
|
197
|
+
FF = float(FSHAPE(UU - Hs[lvl]))
|
|
198
|
+
RKL[lvl][col] += FF
|
|
199
|
+
if FF < EPS:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
# centerLBL_full (see center_lbl.py — unchanged below)
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# ... [paste centerLBL_full verbatim from your center_lbl.py] ...
|
|
206
|
+
# ... [paste rightLBL_full and _right_cascade_full verbatim] ...
|
multigrid/grids.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Multi-resolution grid state (Fortran GridState / Grids module)."""
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GridState:
|
|
10
|
+
"""
|
|
11
|
+
Multi-resolution stencil arrays for the LBL multi-grid algorithm.
|
|
12
|
+
|
|
13
|
+
For each level n in [0..9] there are three arrays:
|
|
14
|
+
RKnP[i] -- 'plus' neighbour contribution at grid point i
|
|
15
|
+
RKn[i] -- 'center' contribution at grid point i
|
|
16
|
+
RKnL[i] -- 'left' neighbour contribution at grid point i
|
|
17
|
+
|
|
18
|
+
NT0..NT9 are the number of grid points at each level.
|
|
19
|
+
RK[i] is the coarsest flat grid (NT points).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, delta_wv: float, cut_off: float, H: float):
|
|
23
|
+
self.deltaWV = delta_wv
|
|
24
|
+
self.cutOff = cut_off
|
|
25
|
+
self.H = H
|
|
26
|
+
|
|
27
|
+
self.H0 = 2.0 * H
|
|
28
|
+
self.H1 = 4.0 * H
|
|
29
|
+
self.H2 = 8.0 * H
|
|
30
|
+
self.H3 = 16.0 * H
|
|
31
|
+
self.H4 = 32.0 * H
|
|
32
|
+
self.H5 = 64.0 * H
|
|
33
|
+
self.H6 = 128.0 * H
|
|
34
|
+
self.H7 = 256.0 * H
|
|
35
|
+
self.H8 = 512.0 * H
|
|
36
|
+
self.H9 = 1024.0 * H
|
|
37
|
+
|
|
38
|
+
self.NT = max(1, int(round(delta_wv / H)))
|
|
39
|
+
self.NT0 = max(1, self.NT // 2)
|
|
40
|
+
self.NT1 = max(1, self.NT // 4)
|
|
41
|
+
self.NT2 = max(1, self.NT // 8)
|
|
42
|
+
self.NT3 = max(1, self.NT // 16)
|
|
43
|
+
self.NT4 = max(1, self.NT // 32)
|
|
44
|
+
self.NT5 = max(1, self.NT // 64)
|
|
45
|
+
self.NT6 = max(1, self.NT // 128)
|
|
46
|
+
self.NT7 = max(1, self.NT // 256)
|
|
47
|
+
self.NT8 = max(1, self.NT // 512)
|
|
48
|
+
self.NT9 = max(1, self.NT // 1024)
|
|
49
|
+
|
|
50
|
+
self._allocate()
|
|
51
|
+
|
|
52
|
+
def _allocate(self):
|
|
53
|
+
def z(n):
|
|
54
|
+
return np.zeros(n + 2) # +2 for 1-based index safety
|
|
55
|
+
|
|
56
|
+
self.RK = z(self.NT)
|
|
57
|
+
|
|
58
|
+
self.RK0P = z(self.NT0); self.RK0 = z(self.NT0); self.RK0L = z(self.NT0)
|
|
59
|
+
self.RK1P = z(self.NT1); self.RK1 = z(self.NT1); self.RK1L = z(self.NT1)
|
|
60
|
+
self.RK2P = z(self.NT2); self.RK2 = z(self.NT2); self.RK2L = z(self.NT2)
|
|
61
|
+
self.RK3P = z(self.NT3); self.RK3 = z(self.NT3); self.RK3L = z(self.NT3)
|
|
62
|
+
self.RK4P = z(self.NT4); self.RK4 = z(self.NT4); self.RK4L = z(self.NT4)
|
|
63
|
+
self.RK5P = z(self.NT5); self.RK5 = z(self.NT5); self.RK5L = z(self.NT5)
|
|
64
|
+
self.RK6P = z(self.NT6); self.RK6 = z(self.NT6); self.RK6L = z(self.NT6)
|
|
65
|
+
self.RK7P = z(self.NT7); self.RK7 = z(self.NT7); self.RK7L = z(self.NT7)
|
|
66
|
+
self.RK8P = z(self.NT8); self.RK8 = z(self.NT8); self.RK8L = z(self.NT8)
|
|
67
|
+
self.RK9P = z(self.NT9); self.RK9 = z(self.NT9); self.RK9L = z(self.NT9)
|
|
68
|
+
|
|
69
|
+
def reset(self):
|
|
70
|
+
"""Zero all arrays (called before each new spectral line)."""
|
|
71
|
+
self._allocate()
|
|
72
|
+
|
|
73
|
+
def reconstruct_fine_grid(self) -> np.ndarray:
|
|
74
|
+
"""Reconstruct the fine-grid array from the stencil hierarchy."""
|
|
75
|
+
result = self.RK[1:self.NT + 1].copy()
|
|
76
|
+
|
|
77
|
+
levels = [
|
|
78
|
+
(self.RK0, self.RK0P, self.RK0L, self.NT0, self.H0),
|
|
79
|
+
(self.RK1, self.RK1P, self.RK1L, self.NT1, self.H1),
|
|
80
|
+
(self.RK2, self.RK2P, self.RK2L, self.NT2, self.H2),
|
|
81
|
+
(self.RK3, self.RK3P, self.RK3L, self.NT3, self.H3),
|
|
82
|
+
(self.RK4, self.RK4P, self.RK4L, self.NT4, self.H4),
|
|
83
|
+
(self.RK5, self.RK5P, self.RK5L, self.NT5, self.H5),
|
|
84
|
+
(self.RK6, self.RK6P, self.RK6L, self.NT6, self.H6),
|
|
85
|
+
(self.RK7, self.RK7P, self.RK7L, self.NT7, self.H7),
|
|
86
|
+
(self.RK8, self.RK8P, self.RK8L, self.NT8, self.H8),
|
|
87
|
+
(self.RK9, self.RK9P, self.RK9L, self.NT9, self.H9),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
NT = self.NT
|
|
91
|
+
for RKn, RKnP, RKnL, NTn, Hn in levels:
|
|
92
|
+
ratio = max(1, int(round(Hn / self.H)))
|
|
93
|
+
i_arr = np.arange(1, NTn + 1)
|
|
94
|
+
c_idx = i_arr * ratio - 1
|
|
95
|
+
l_idx = c_idx - ratio // 2
|
|
96
|
+
r_idx = c_idx + ratio // 2
|
|
97
|
+
|
|
98
|
+
for idx_arr, RK_arr in ((c_idx, RKn),
|
|
99
|
+
(l_idx, RKnL),
|
|
100
|
+
(r_idx, RKnP)):
|
|
101
|
+
valid = (idx_arr >= 0) & (idx_arr < NT)
|
|
102
|
+
if np.any(valid):
|
|
103
|
+
np.add.at(result, idx_arr[valid],
|
|
104
|
+
RK_arr[1:NTn + 1][valid])
|
|
105
|
+
|
|
106
|
+
return result
|
multigrid/hitran.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""HITRAN spectral line reader."""
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from .molecules import MOLECULE_NAMES
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SpectralLine:
|
|
14
|
+
"""HITRAN spectral line parameters."""
|
|
15
|
+
mol_id: int
|
|
16
|
+
iso_id: int
|
|
17
|
+
nu: float
|
|
18
|
+
S: float
|
|
19
|
+
A: float
|
|
20
|
+
gamma_air: float
|
|
21
|
+
gamma_self: float
|
|
22
|
+
E_low: float
|
|
23
|
+
n_air: float
|
|
24
|
+
delta_air: float
|
|
25
|
+
g_upper: int = 0
|
|
26
|
+
g_lower: int = 0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HITRANReader:
|
|
30
|
+
"""Read HITRAN ``.par`` files into :class:`SpectralLine` objects."""
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def read_par_file(
|
|
34
|
+
filename: str,
|
|
35
|
+
molecule_id: Optional[int] = None,
|
|
36
|
+
wavenumber_min: Optional[float] = None,
|
|
37
|
+
wavenumber_max: Optional[float] = None,
|
|
38
|
+
intensity_threshold: Optional[float] = None,
|
|
39
|
+
) -> List[SpectralLine]:
|
|
40
|
+
lines: List[SpectralLine] = []
|
|
41
|
+
|
|
42
|
+
if not os.path.exists(filename):
|
|
43
|
+
logger.warning("HITRAN file not found: %s", filename)
|
|
44
|
+
return lines
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
with open(filename, "r") as f:
|
|
48
|
+
for lineno, line_str in enumerate(f, start=1):
|
|
49
|
+
if len(line_str) < 160:
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
mol = int(line_str[0:2])
|
|
53
|
+
iso = int(line_str[2])
|
|
54
|
+
nu = float(line_str[3:15])
|
|
55
|
+
S = float(line_str[15:25])
|
|
56
|
+
A = float(line_str[25:35])
|
|
57
|
+
gamma_air = float(line_str[35:40])
|
|
58
|
+
gamma_self = float(line_str[40:45])
|
|
59
|
+
E_low = float(line_str[45:55])
|
|
60
|
+
n_air = float(line_str[55:59])
|
|
61
|
+
delta_air = float(line_str[59:67])
|
|
62
|
+
except (ValueError, IndexError):
|
|
63
|
+
logger.debug("Skipping malformed line %d in %s",
|
|
64
|
+
lineno, filename)
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
if molecule_id is not None and mol != molecule_id:
|
|
68
|
+
continue
|
|
69
|
+
if wavenumber_min is not None and nu < wavenumber_min:
|
|
70
|
+
continue
|
|
71
|
+
if wavenumber_max is not None and nu > wavenumber_max:
|
|
72
|
+
continue
|
|
73
|
+
if intensity_threshold is not None and S < intensity_threshold:
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
lines.append(SpectralLine(
|
|
77
|
+
mol_id=mol, iso_id=iso, nu=nu, S=S, A=A,
|
|
78
|
+
gamma_air=gamma_air, gamma_self=gamma_self,
|
|
79
|
+
E_low=E_low, n_air=n_air, delta_air=delta_air,
|
|
80
|
+
))
|
|
81
|
+
except OSError:
|
|
82
|
+
logger.exception("Error reading %s", filename)
|
|
83
|
+
return lines
|
|
84
|
+
|
|
85
|
+
if lines:
|
|
86
|
+
name = MOLECULE_NAMES.get(molecule_id, "Unknown")
|
|
87
|
+
logger.info("%s: %d lines loaded from %s", name, len(lines), filename)
|
|
88
|
+
else:
|
|
89
|
+
logger.warning("No lines matched the filters in %s", filename)
|
|
90
|
+
|
|
91
|
+
return lines
|
multigrid/line_shapes.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Spectral line shape functions."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
from scipy.special import wofz
|
|
4
|
+
|
|
5
|
+
from .constants import Constants
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LineShapes:
|
|
9
|
+
"""Spectral line shape functions matching Fortran LineShapes module."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def voigt_humlicek(x: np.ndarray, a: float) -> np.ndarray:
|
|
13
|
+
"""Voigt function via Humlíček (1982) / Faddeeva function."""
|
|
14
|
+
return np.real(wofz(x + 1j * a)) / Constants.sqrt_pi
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def voigt_normalized(x: np.ndarray,
|
|
18
|
+
gamma_L: float,
|
|
19
|
+
gamma_D: float) -> np.ndarray:
|
|
20
|
+
"""
|
|
21
|
+
Voigt profile normalised so that its integral over all x is 1.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
x : offset from line centre [cm-1]
|
|
26
|
+
gamma_L : Lorentz (pressure) half-width [cm-1]
|
|
27
|
+
gamma_D : Doppler half-width [cm-1]
|
|
28
|
+
"""
|
|
29
|
+
if gamma_D == 0:
|
|
30
|
+
return LineShapes.lorentz(x, gamma_L)
|
|
31
|
+
if gamma_L == 0:
|
|
32
|
+
return LineShapes.doppler(x, gamma_D)
|
|
33
|
+
|
|
34
|
+
x_reduced = x / gamma_D
|
|
35
|
+
a = gamma_L / gamma_D
|
|
36
|
+
K = LineShapes.voigt_humlicek(x_reduced, a)
|
|
37
|
+
# voigt_humlicek already returns Re[w(z)]/sqrt(pi); dividing by
|
|
38
|
+
# gamma_D alone yields a profile whose integral over all x equals 1.
|
|
39
|
+
return K / gamma_D
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def lorentz(x: np.ndarray, gamma: float) -> np.ndarray:
|
|
43
|
+
return gamma / (np.pi * (x ** 2 + gamma ** 2))
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def doppler(x: np.ndarray, gamma_D: float) -> np.ndarray:
|
|
47
|
+
return (Constants.sqrt_ln2 / (gamma_D * Constants.sqrt_pi)) * \
|
|
48
|
+
np.exp(-Constants.ln2_pi * (x / gamma_D) ** 2)
|
multigrid/molecules.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
MOLECULE_NAMES = {
|
|
2
|
+
1: "H2O", 2: "CO2", 3: "O3", 4: "N2O", 5: "CO",
|
|
3
|
+
6: "CH4", 7: "O2", 8: "NO", 9: "SO2", 10: "NO2",
|
|
4
|
+
11: "NH3", 12: "HNO3",
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
MOLECULAR_MASSES = {
|
|
8
|
+
1: 18.010565, 2: 43.989830, 3: 47.984745, 4: 44.001062,
|
|
9
|
+
5: 27.994915, 6: 16.031300, 7: 31.989830, 8: 29.997989,
|
|
10
|
+
9: 63.961901, 10: 45.992904, 11: 17.026549, 12: 62.995644,
|
|
11
|
+
}
|
multigrid/plotting.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Optional plotting helpers.
|
|
2
|
+
|
|
3
|
+
Requires matplotlib. matplotlib is imported lazily inside each function
|
|
4
|
+
so that ``import multigrid`` never pulls it in.
|
|
5
|
+
"""
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def plot_spectrum(
|
|
12
|
+
nu: np.ndarray,
|
|
13
|
+
alpha: np.ndarray,
|
|
14
|
+
*,
|
|
15
|
+
title: Optional[str] = None,
|
|
16
|
+
filename: Optional[str] = None,
|
|
17
|
+
show: bool = True,
|
|
18
|
+
ax=None,
|
|
19
|
+
):
|
|
20
|
+
"""
|
|
21
|
+
Plot an absorption spectrum.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
nu : wavenumber grid [cm-1]
|
|
26
|
+
alpha : absorption coefficient [cm-1]
|
|
27
|
+
title : optional plot title
|
|
28
|
+
filename : if given, save the figure to this path
|
|
29
|
+
show : if True, call ``plt.show()``
|
|
30
|
+
ax : optional existing matplotlib Axes to draw on
|
|
31
|
+
"""
|
|
32
|
+
import matplotlib.pyplot as plt # lazy — only when actually plotting
|
|
33
|
+
|
|
34
|
+
if ax is None:
|
|
35
|
+
fig, ax = plt.subplots(figsize=(12, 5))
|
|
36
|
+
|
|
37
|
+
ax.plot(nu, alpha, "b-", linewidth=0.6)
|
|
38
|
+
ax.set_xlabel("Wavenumber (cm⁻¹)")
|
|
39
|
+
ax.set_ylabel("Absorption coefficient (cm⁻¹)")
|
|
40
|
+
if title:
|
|
41
|
+
ax.set_title(title)
|
|
42
|
+
ax.grid(True, alpha=0.3)
|
|
43
|
+
ax.ticklabel_format(style="scientific", axis="y", scilimits=(0, 0))
|
|
44
|
+
|
|
45
|
+
if filename:
|
|
46
|
+
plt.tight_layout()
|
|
47
|
+
plt.savefig(filename, dpi=300)
|
|
48
|
+
if show:
|
|
49
|
+
plt.show()
|
|
50
|
+
return ax
|
multigrid/profiles.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Atmospheric profiles."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Dict
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class AtmosphericProfile:
|
|
10
|
+
"""Vertical profile of a planetary atmosphere."""
|
|
11
|
+
|
|
12
|
+
z: np.ndarray
|
|
13
|
+
P: np.ndarray
|
|
14
|
+
T: np.ndarray
|
|
15
|
+
vmr: Dict[int, np.ndarray]
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def us_standard(cls) -> "AtmosphericProfile":
|
|
19
|
+
"""Very coarse US Standard Atmosphere (7 levels)."""
|
|
20
|
+
z = np.array([0, 10, 20, 30, 50, 70, 100], dtype=float)
|
|
21
|
+
P = np.array([1.0, 0.265, 0.055, 0.012, 0.001, 0.00005, 0.000003])
|
|
22
|
+
T = np.array([288, 223, 217, 227, 271, 220, 210], dtype=float)
|
|
23
|
+
vmr = {2: 400e-6 * np.ones_like(z)}
|
|
24
|
+
return cls(z, P, T, vmr)
|
multigrid/pt_table.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""PT-table generator for radiative-transfer codes."""
|
|
2
|
+
import logging
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .core import MARFA
|
|
8
|
+
from .hitran import SpectralLine
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PTTableGenerator:
|
|
14
|
+
"""Generate a (P, T, nu) lookup table of absorption coefficients."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, marfa: MARFA):
|
|
17
|
+
self.marfa = marfa
|
|
18
|
+
|
|
19
|
+
def generate_table(
|
|
20
|
+
self,
|
|
21
|
+
nu_grid: np.ndarray,
|
|
22
|
+
lines: List[SpectralLine],
|
|
23
|
+
P_grid: np.ndarray,
|
|
24
|
+
T_grid: np.ndarray,
|
|
25
|
+
mole_fraction: float,
|
|
26
|
+
output_file: str,
|
|
27
|
+
) -> np.ndarray:
|
|
28
|
+
logger.info("Generating PT table: %d P x %d T x %d nu",
|
|
29
|
+
len(P_grid), len(T_grid), len(nu_grid))
|
|
30
|
+
|
|
31
|
+
n_P, n_T, n_nu = len(P_grid), len(T_grid), len(nu_grid)
|
|
32
|
+
table = np.zeros((n_P, n_T, n_nu))
|
|
33
|
+
|
|
34
|
+
for i, P in enumerate(P_grid):
|
|
35
|
+
for j, T in enumerate(T_grid):
|
|
36
|
+
logger.debug("P=%.3e atm T=%.1f K", P, T)
|
|
37
|
+
table[i, j, :] = self.marfa.calculate_absorption_coefficient(
|
|
38
|
+
nu_grid, lines, T, P, mole_fraction)
|
|
39
|
+
|
|
40
|
+
np.save(output_file, table)
|
|
41
|
+
logger.info("Saved PT table to %s", output_file)
|
|
42
|
+
return table
|
multigrid/tips.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Total Internal Partition Sums (TIPS)."""
|
|
2
|
+
import logging
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TIPS:
|
|
10
|
+
"""
|
|
11
|
+
Total Internal Partition Sums.
|
|
12
|
+
|
|
13
|
+
NOTE
|
|
14
|
+
----
|
|
15
|
+
The temperature dependence implemented here is a rough T**1.5 scaling
|
|
16
|
+
anchored at 296 K. It is adequate for qualitative work but is NOT the
|
|
17
|
+
real Gamache et al. (2017) TIPS database. Replace `_calculate_tips_table`
|
|
18
|
+
with a real table lookup for quantitative accuracy.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.Q_ref_296 = {
|
|
23
|
+
1: 178.12, 2: 289.49, 3: 4870.3, 4: 1122.3,
|
|
24
|
+
5: 108.58, 6: 590.43, 7: 216.21, 8: 159.47,
|
|
25
|
+
9: 5792.6, 10: 2379.5, 11: 169.24, 12: 11456.0,
|
|
26
|
+
}
|
|
27
|
+
self.T_grid = np.arange(20, 1002, 2)
|
|
28
|
+
self._calculate_tips_table()
|
|
29
|
+
|
|
30
|
+
def _calculate_tips_table(self):
|
|
31
|
+
self.tips_table = {}
|
|
32
|
+
for mol_id in range(1, 13):
|
|
33
|
+
Q_ref = self.Q_ref_296.get(mol_id, 100.0)
|
|
34
|
+
self.tips_table[mol_id] = Q_ref * (self.T_grid / 296.0) ** 1.5
|
|
35
|
+
|
|
36
|
+
def get_Q(self, mol_id: int, T: float) -> float:
|
|
37
|
+
if mol_id not in self.tips_table:
|
|
38
|
+
logger.debug("Unknown molecule id %d, falling back to CO2", mol_id)
|
|
39
|
+
mol_id = 2
|
|
40
|
+
T = float(np.clip(T, 20.0, 1000.0))
|
|
41
|
+
idx = int((T - 20.0) / 2.0)
|
|
42
|
+
idx = max(0, min(idx, len(self.T_grid) - 2))
|
|
43
|
+
T1, T2 = self.T_grid[idx], self.T_grid[idx + 1]
|
|
44
|
+
Q1, Q2 = self.tips_table[mol_id][idx], self.tips_table[mol_id][idx + 1]
|
|
45
|
+
return float(Q1 + (Q2 - Q1) * (T - T1) / (T2 - T1))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Sub-Lorentzian wing correction (chi-factor) functions."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class WingCorrections:
|
|
6
|
+
"""Sub-Lorentzian wing correction functions."""
|
|
7
|
+
|
|
8
|
+
@staticmethod
|
|
9
|
+
def tonkov_chi(delta_nu: np.ndarray, nu0: float) -> np.ndarray:
|
|
10
|
+
abs_delta = np.abs(delta_nu)
|
|
11
|
+
chi = np.ones_like(delta_nu, dtype=float)
|
|
12
|
+
mask2 = (abs_delta > 25.0) & (abs_delta <= 250.0)
|
|
13
|
+
chi[mask2] = 1.0 - 0.02 * ((abs_delta[mask2] - 25.0) / 225.0) ** 2
|
|
14
|
+
mask3 = abs_delta > 250.0
|
|
15
|
+
chi[mask3] = 0.98 * np.exp(-(abs_delta[mask3] - 250.0) / 200.0)
|
|
16
|
+
return chi
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def perrin_chi(delta_nu: np.ndarray, nu0: float) -> np.ndarray:
|
|
20
|
+
abs_delta = np.abs(delta_nu)
|
|
21
|
+
chi = np.ones_like(delta_nu, dtype=float)
|
|
22
|
+
mask = abs_delta > 20.0
|
|
23
|
+
chi[mask] = np.exp(-0.0015 * (abs_delta[mask] - 20.0))
|
|
24
|
+
return chi
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def no_correction(delta_nu: np.ndarray, nu0: float) -> np.ndarray:
|
|
28
|
+
return np.ones_like(delta_nu, dtype=float)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Osama and MARFA contributors
|
|
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.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: multigrid-py
|
|
3
|
+
Version: 3.0.1
|
|
4
|
+
Summary: Python port of the Fortran MARFA line-by-line molecular absorption code
|
|
5
|
+
Home-page: https://github.com/os1832000-png/MultiGrid_Py
|
|
6
|
+
License: MIT
|
|
7
|
+
Platform: UNKNOWN
|
|
8
|
+
Requires-Python: >=3.6
|
|
9
|
+
Requires-Dist: numpy (>=1.16)
|
|
10
|
+
Requires-Dist: scipy (>=1.2)
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest ; extra == 'dev'
|
|
13
|
+
Provides-Extra: plot
|
|
14
|
+
Requires-Dist: matplotlib (>=3.0) ; extra == 'plot'
|
|
15
|
+
|
|
16
|
+
UNKNOWN
|
|
17
|
+
|
|
18
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
multigrid/__init__.py,sha256=yP7j67ExcE7VRGKVyqimv7LgoYZ9GDoord9zr7HpKRI,1100
|
|
2
|
+
multigrid/_warnings.py,sha256=_YX2gIzwnET8fP1KLsd-MTvtX_tKeWXg9EgbXCQz1v8,334
|
|
3
|
+
multigrid/cli.py,sha256=ua_4uD2ZRNgvays4DfjOe8ZTl9xrYnzjbDtcLecY5wA,2371
|
|
4
|
+
multigrid/constants.py,sha256=dAHQ8P6-o1Al_BV8jv74Sfm_DsspeK_zxT2dEHb75UA,860
|
|
5
|
+
multigrid/core.py,sha256=6oz4n08OWDrfan7MfJMka2cIAg9w0odtz6Y2E1QwO5Y,5331
|
|
6
|
+
multigrid/grid_calculator.py,sha256=11InH8Uwj6QIxPN1c7LEwj6dg1gGeINR9_tH3k3i01g,6144
|
|
7
|
+
multigrid/grids.py,sha256=QqXdIhYf5w91cCYgFjOFt3d4uVZx7G1EB15F7iN0zuk,4139
|
|
8
|
+
multigrid/hitran.py,sha256=8oTg-C4ysi2hbaULwJip8Ry506dmmDlGLZXoiIHQrws,3242
|
|
9
|
+
multigrid/line_shapes.py,sha256=ETI6hW-v2a8BoOa9dZbll57efc2Vn29_oxeQpbPYZNQ,1659
|
|
10
|
+
multigrid/molecules.py,sha256=jjCZAiyPqk56QrFniqFE7TpNdn4OjVDyzD0fX3srUVg,364
|
|
11
|
+
multigrid/plotting.py,sha256=HP14lpPHgysswLJl2tWNlZ6N18e27aJuuvUQpcCsMi4,1297
|
|
12
|
+
multigrid/profiles.py,sha256=xmXkapKDOYrJq43GlC3EnkrBWKHIiZLMSYEipXcs-9Q,712
|
|
13
|
+
multigrid/pt_table.py,sha256=_APFb6gkCIdoBiIMuOUag0UoQWFty1zx10EvXD-OalQ,1234
|
|
14
|
+
multigrid/tips.py,sha256=quxxMuHeTaxIwynpgZ3y89pe_XXzTR21FNUdG77kLZA,1567
|
|
15
|
+
multigrid/wing_corrections.py,sha256=9MIVHhv6Oy2OXVJ_t8T4ucmXPrgTJyKxKrH8j_wxm7w,1017
|
|
16
|
+
multigrid_py-3.0.1.dist-info/LICENSE,sha256=bbaWnq3frG3ZSf0UBZxWnatxXDdBALQtiVioQNVe3gU,1085
|
|
17
|
+
multigrid_py-3.0.1.dist-info/METADATA,sha256=fQdzRk7qaypzjMb_V9mDlTH48HcSEbmFJxVhw4NO2aQ,450
|
|
18
|
+
multigrid_py-3.0.1.dist-info/WHEEL,sha256=g4nMs7d-Xl9-xC9XovUrsDHGXt-FT0E17Yqo92DEfvY,92
|
|
19
|
+
multigrid_py-3.0.1.dist-info/entry_points.txt,sha256=1yC203C3PM83xNIn0P9NLbFR5IaBPN6AQf1ClzHksEc,50
|
|
20
|
+
multigrid_py-3.0.1.dist-info/top_level.txt,sha256=IizlWsvgs3-JDnPAAgLXMo8Re7upWmqJkKkZNGfh3YE,10
|
|
21
|
+
multigrid_py-3.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
multigrid
|