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 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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.34.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ multigrid = multigrid.cli:main
3
+
@@ -0,0 +1 @@
1
+ multigrid