dust-compendium 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.5
2
+ Name: dust-compendium
3
+ Version: 0.1.0
4
+ Summary: Dust attenuation tables for simple galactic geometries, computed with Hyperion
5
+ Project-URL: Homepage, https://github.com/galacticusorg/dust-compendium
6
+ Project-URL: Documentation, https://dust-compendium.readthedocs.io
7
+ Project-URL: Issues, https://github.com/galacticusorg/dust-compendium/issues
8
+ Author-email: Andrew Benson <abenson@carnegiescience.edu>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: astronomy,dust,galacticus,galaxies,radiative transfer
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
16
+ Requires-Python: >=3.11
17
+ Requires-Dist: h5py>=3.8
18
+ Requires-Dist: numpy>=1.24
19
+ Requires-Dist: pydantic>=2.0
20
+ Requires-Dist: pyyaml>=6.0
21
+ Requires-Dist: scipy>=1.10
22
+ Requires-Dist: typer>=0.12
23
+ Provides-Extra: dev
24
+ Requires-Dist: pre-commit>=3.5; extra == 'dev'
25
+ Requires-Dist: pytest-cov; extra == 'dev'
26
+ Requires-Dist: pytest>=7.4; extra == 'dev'
27
+ Requires-Dist: ruff==0.16.7; extra == 'dev'
28
+ Provides-Extra: docs
29
+ Requires-Dist: myst-parser>=2.0; extra == 'docs'
30
+ Requires-Dist: sphinx-autodoc-typehints; extra == 'docs'
31
+ Requires-Dist: sphinx-book-theme; extra == 'docs'
32
+ Requires-Dist: sphinx>=7.0; extra == 'docs'
33
+ Provides-Extra: plots
34
+ Requires-Dist: matplotlib>=3.7; extra == 'plots'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # Dust Compendium
38
+
39
+ [![CI](https://github.com/galacticusorg/dust-compendium/actions/workflows/ci.yml/badge.svg)](https://github.com/galacticusorg/dust-compendium/actions/workflows/ci.yml)
40
+ [![Documentation](https://readthedocs.org/projects/dust-compendium/badge/?version=latest)](https://dust-compendium.readthedocs.io)
41
+
42
+ Dust attenuation tables for simple galactic geometries, computed by running the
43
+ [Hyperion](http://www.hyperion-rt.org) Monte Carlo radiative transfer code over
44
+ a grid of galaxy models.
45
+
46
+ A tabulation gives the fraction of starlight escaping a galaxy as a function of
47
+ wavelength, inclination, the optical depth of each dusty component, and the
48
+ sizes of the components themselves. [Galacticus](https://github.com/galacticusorg/galacticus)
49
+ reads them through its `dustAttenuationAtlasCompendium` class. The method is
50
+ described in [Benson (2018)](https://ui.adsabs.harvard.edu/abs/2018RNAAS...2..188B).
51
+
52
+ Full documentation is at [dust-compendium.readthedocs.io](https://dust-compendium.readthedocs.io).
53
+
54
+ ## Installation
55
+
56
+ ```bash
57
+ pip install dust-compendium
58
+ ```
59
+
60
+ That gives the library and the command line tool. Computing *new* tabulations
61
+ additionally needs Hyperion, in two parts, neither of which is a dependency of
62
+ this package:
63
+
64
+ - its **Python front-end**, which has to come from source for now — the current
65
+ PyPI release calls `np.string_` when writing a model, which NumPy 2 removed;
66
+ - its **Fortran solvers**, which `pip` does not install at all.
67
+
68
+ See [the installation notes](https://dust-compendium.readthedocs.io/en/latest/installation.html).
69
+
70
+ ## Using it
71
+
72
+ ```bash
73
+ dust-compendium validate configs/compendium-d03-rv3.1.yaml # what would this run?
74
+ dust-compendium build configs/compendium-d03-rv3.1.yaml # write the model inputs
75
+ dust-compendium run configs/compendium-d03-rv3.1.yaml # solve them, here or on Slurm
76
+ dust-compendium collect configs/compendium-d03-rv3.1.yaml # assemble the tabulation
77
+ dust-compendium plot attenuations.hdf5 -q curve -c opticalDepth
78
+ dust-compendium compare attenuations.hdf5 --atlas /path/to/atlas.hdf5
79
+ ```
80
+
81
+ A campaign is described by a YAML file. Any value written as a range becomes an
82
+ axis of the tabulation; a galaxy is a set of named components, each of which may
83
+ carry stars, dust, both or neither. Each step reads what the one before it left
84
+ on disk, so any of them can be interrupted and resumed.
85
+
86
+ `configs/` holds the configuration for the published Draine R_V=3.1 tabulation,
87
+ one adding dust to the spheroid, and one matched to Ferrara et al. (1999) for
88
+ validation.
89
+
90
+ ## Validation
91
+
92
+ Reproducing Ferrara et al. (1999) with matched grains, geometry and grids gives
93
+ a median absolute difference from their published atlas of **0.0034** in disk
94
+ transmission, with 99.9% of entries within 0.02 — as close as the original
95
+ scripts got, and near the 0.005 floor set by the atlas being published to two
96
+ decimal places.
97
+
98
+ ## Provenance
99
+
100
+ This replaces the Perl and Python 2 scripts written for the 2018 paper, which
101
+ are preserved verbatim in the first commit of this repository. Rewriting them
102
+ turned up several defects, two of which reached published data; those are
103
+ described under
104
+ [Published datasets](https://dust-compendium.readthedocs.io/en/latest/datasets.html).
105
+
106
+ ## License
107
+
108
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,30 @@
1
+ dustcompendium/__init__.py,sha256=7rA1LA0SktQZR215XSlteRjjNxDvzPsWhxRg2iOBSqA,479
2
+ dustcompendium/campaign.py,sha256=UJoTc_t6UmDrc4lKVbQuVbvuMbopXTctpPRCuu6bv-c,10878
3
+ dustcompendium/cli.py,sha256=wB0x8cJ2RMU5DYWq52X_v35Fg55CI4xQ4o6X3cLR-C4,18736
4
+ dustcompendium/config.py,sha256=ds6u3hVxrd5IBMKqRJzbUQCNqCPwU1Sg9kfxUE7DMXY,9433
5
+ dustcompendium/galaxy.py,sha256=af_02zpCKdQNxcIQ6o0CgaIZGTAhTaCKEx1VjLScaEk,5672
6
+ dustcompendium/grid.py,sha256=skfpvX2aEp6wuhsaK0BPyIUfiKyc2LdmaA8cxH4FoYo,9302
7
+ dustcompendium/hyperion_model.py,sha256=gKc_-s_pkX6eXRFOnnpz4lWp3SVrkewczYUpAh0O_z8,5418
8
+ dustcompendium/model.py,sha256=0DyM2cB0YoNNrVMrB_GgQ4i44KVqLYTHMp7eWpyOU54,9969
9
+ dustcompendium/plots.py,sha256=lGG7Ms7gBzGttr3BI3WwWsbP-kxnYqJrRXc3r7YLai4,12710
10
+ dustcompendium/postprocess.py,sha256=VOW7lNt9V_Uvcs97i7oLJNdDdS8-Si8PLdmaCO0xqzk,12469
11
+ dustcompendium/tabulation.py,sha256=YlyWFKTFvY5IUdfY-RvfZA6jyjo4yZ8wrcC10hS3Cis,12159
12
+ dustcompendium/validation.py,sha256=tlo7ZSgM3gVLCTEFtlRcpuYTSE6XYuTpUUcB9lvIViA,5822
13
+ dustcompendium/dust/__init__.py,sha256=yWcio7isC6u3AavSXuRPKy1Gw6LLLKtn3FXujasvYYk,316
14
+ dustcompendium/dust/ferrara.py,sha256=KkNPgTT8hf38MjjBpNLkqQQLhyBZzWWQHAkABLnSSDw,5286
15
+ dustcompendium/dust/properties.py,sha256=RNPPbpTp7J77Ja1XWmPzNYaS0HnpV5jlgLYQXCAJYak,2707
16
+ dustcompendium/dust/data/gordon1997.csv,sha256=v7r1Sk7kzfsxx6hiR-8IYOW4gY6IJCLENMR47DC7L5U,1325
17
+ dustcompendium/geometry/__init__.py,sha256=VjSuSP2OdoFKMaWnkIUMKAq5LOO8u2Sx4bzCft4SFTU,2342
18
+ dustcompendium/geometry/base.py,sha256=TkSR68lnX9jgG1od6Z0YQ11BDb-1FnqVPyCTPqckDpU,5867
19
+ dustcompendium/geometry/disk.py,sha256=SXsgKZtmmcXL6mQxMtov-gZmqThnoL_vc5g45uR8NHw,6150
20
+ dustcompendium/geometry/spheroid.py,sha256=emyUi8n_PE0-Wq-Er6uMPVBfd9S1d5oVhtUVa0TLj4E,17987
21
+ dustcompendium/runner/__init__.py,sha256=lJ4INQBG_XywZZbe6asJrPRppZEPMR05cU3mLtVYG58,1132
22
+ dustcompendium/runner/base.py,sha256=NJUYjAR83hBxNZSlsT_RLHvst5IyvsuAcAFs8P0KxSc,4037
23
+ dustcompendium/runner/local.py,sha256=gXXUzcWivrmCROuD75py-I86FlLH5-l2O4X-h4M9DBA,2104
24
+ dustcompendium/runner/slurm.py,sha256=GLieuIVAgD92VChxTmE7ERRIs7PEgbp_DiPVuKERG08,7155
25
+ dustcompendium/runner/solver.py,sha256=AnGX7widY1v3YpxCWIHR503pN5gIod8YVGNPR7YaCKM,3695
26
+ dust_compendium-0.1.0.dist-info/METADATA,sha256=AjrtzjNWX2GmWGpwsS2ebRQnlc1mrmLqbPIVqEgSQdM,4735
27
+ dust_compendium-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
28
+ dust_compendium-0.1.0.dist-info/entry_points.txt,sha256=I1I2b9Ss3aSvG_jZyOiXnNK7Gh5ERwoqDKLCVDUj960,59
29
+ dust_compendium-0.1.0.dist-info/licenses/LICENSE,sha256=Dg5aovcCb-1jZaq8wyx5dK6xzuTA-RmIzbhF4-cBYy0,1075
30
+ dust_compendium-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dust-compendium = dustcompendium.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2018-2026 Andrew Benson
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,14 @@
1
+ """Dust attenuation tables for simple galactic geometries.
2
+
3
+ Computes the fraction of starlight escaping a galaxy as a function of
4
+ wavelength, inclination, dust optical depth and component size, by running the
5
+ Hyperion Monte Carlo radiative transfer code over a grid of simple galactic
6
+ geometries, and tabulates the result.
7
+
8
+ The method is described in `Benson (2018)
9
+ <https://ui.adsabs.harvard.edu/abs/2018RNAAS...2..188B>`_.
10
+ """
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = ["__version__"]
@@ -0,0 +1,259 @@
1
+ r"""Expanding a configuration into the individual models that have to be run.
2
+
3
+ A campaign tabulates attenuation over a grid of parameters. Every parameter
4
+ written as a range in the configuration becomes an *axis*: the optical depth of
5
+ each dust-bearing component, and any geometric scale that varies. One model is
6
+ run for each point on that grid and for each component with stars, since the
7
+ attenuation of each is tabulated separately.
8
+
9
+ The published compendium is 60 optical depths by 51 spheroid sizes by two
10
+ emitting components, which is 6,120 models -- why this wants a cluster.
11
+ """
12
+
13
+ from collections.abc import Iterator, Mapping, Sequence
14
+ from dataclasses import dataclass
15
+ from itertools import product
16
+
17
+ import numpy as np
18
+ from numpy.typing import NDArray
19
+
20
+ from .config import CampaignConfig, ProfileConfig, values_of
21
+ from .galaxy import Component, Galaxy
22
+ from .model import ModelSpec
23
+
24
+ __all__ = ["Axis", "Campaign", "Run"]
25
+
26
+ #: Geometric parameters which may vary, and so become axes.
27
+ GEOMETRIC = ("scale_radial", "scale_height")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Axis:
32
+ """One tabulated parameter, and the values it takes."""
33
+
34
+ kind: str
35
+ """``opticalDepth``, ``scaleRadial`` or ``scaleHeight``."""
36
+ component: str
37
+ """Which component it belongs to."""
38
+ roles: frozenset[str]
39
+ """Which of ``stellar`` and ``dust`` this axis sets.
40
+
41
+ A component's stars and its dust may have different scale lengths -- the
42
+ original already gave the disk separate stellar and dust scale heights -- so
43
+ the two are separate parameters. When they are configured over the same
44
+ values they are one axis, driving both, rather than a square grid of a scale
45
+ against itself.
46
+ """
47
+ values: NDArray[np.float64]
48
+ qualified: bool = False
49
+ """Whether the name needs a role to tell it from another axis of the same parameter."""
50
+
51
+ @property
52
+ def name(self) -> str:
53
+ """A stable name, used for labels and for output dataset names."""
54
+ if self.qualified:
55
+ return f"{self.kind}:{self.component}:{sorted(self.roles)[0]}"
56
+ return f"{self.kind}:{self.component}"
57
+
58
+ def affects(self, emitter: str) -> bool:
59
+ """Whether this axis can change what the given emitter's light looks like.
60
+
61
+ Attenuation depends on every dust distribution, but only on the stellar
62
+ distribution of the component being observed. So a geometric axis
63
+ belonging to another component's *stars* changes nothing, and tabulating
64
+ against it would repeat the same model. The original arranged this by
65
+ hand, pinning the spheroid scale to a single value when the disk was
66
+ emitting, which is why ``attenuationDisk`` carries no spheroid axis.
67
+ """
68
+ return "dust" in self.roles or self.component == emitter
69
+
70
+ def __len__(self) -> int:
71
+ return int(self.values.size)
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Run:
76
+ """One model: a point on the parameter grid, seen from one emitting component."""
77
+
78
+ emitter: str
79
+ indices: tuple[int, ...]
80
+ """Index into each of :attr:`Campaign.axes`, in order."""
81
+ spec: ModelSpec
82
+ label: str
83
+
84
+ @property
85
+ def file_stem(self) -> str:
86
+ """A file name stem, with the characters a path would rather not carry."""
87
+ return self.label.replace(":", "_").replace("/", "_").replace(" ", "_")
88
+
89
+
90
+ class Campaign:
91
+ """The set of models a configuration describes.
92
+
93
+ Parameters
94
+ ----------
95
+ config
96
+ The parsed configuration.
97
+ """
98
+
99
+ def __init__(self, config: CampaignConfig) -> None:
100
+ self.config = config
101
+ self.axes = tuple(self._axes())
102
+ self.wavelengths = values_of(config.tabulation.wavelengths)
103
+ self.inclinations = values_of(config.tabulation.inclinations)
104
+
105
+ def _axes(self) -> Iterator[Axis]:
106
+ for name, component in self.config.geometry.components.items():
107
+ roles = (("stellar", component.stellar), ("dust", component.dust))
108
+ for role, profile in roles:
109
+ if profile is None or role != "dust":
110
+ continue
111
+ if profile.optical_depth is not None:
112
+ yield Axis("opticalDepth", name, frozenset({role}), values_of(profile.optical_depth))
113
+ for parameter in GEOMETRIC:
114
+ kind = parameter.replace("_r", "R").replace("_h", "H")
115
+ # Group the roles which vary this parameter over identical
116
+ # values; each distinct set of values is one axis.
117
+ groups: list[tuple[NDArray[np.float64], set[str]]] = []
118
+ for role, profile in roles:
119
+ if profile is None or getattr(profile, parameter, None) is None:
120
+ continue
121
+ values = values_of(getattr(profile, parameter))
122
+ if values.size <= 1:
123
+ continue
124
+ for existing, members in groups:
125
+ if existing.shape == values.shape and np.array_equal(existing, values):
126
+ members.add(role)
127
+ break
128
+ else:
129
+ groups.append((values, {role}))
130
+ for values, members in groups:
131
+ yield Axis(kind, name, frozenset(members), values, qualified=len(groups) > 1)
132
+
133
+ @property
134
+ def emitters(self) -> tuple[str, ...]:
135
+ """Components with stars, each of which is tabulated separately."""
136
+ return tuple(
137
+ name
138
+ for name, component in self.config.geometry.components.items()
139
+ if component.stellar is not None
140
+ )
141
+
142
+ def axes_for(self, emitter: str) -> tuple[Axis, ...]:
143
+ """The axes the given emitter is actually tabulated against."""
144
+ return tuple(axis for axis in self.axes if axis.affects(emitter))
145
+
146
+ def shape_for(self, emitter: str) -> tuple[int, ...]:
147
+ """Length of each axis the given emitter is tabulated against."""
148
+ return tuple(len(axis) for axis in self.axes_for(emitter))
149
+
150
+ @property
151
+ def shape(self) -> tuple[int, ...]:
152
+ """Length of each axis, in order."""
153
+ return tuple(len(axis) for axis in self.axes)
154
+
155
+ def __len__(self) -> int:
156
+ """How many models the campaign contains."""
157
+ return sum(int(np.prod(self.shape_for(emitter), dtype=int)) for emitter in self.emitters)
158
+
159
+ def _chosen(
160
+ self, indices: Sequence[int], axes: Sequence[Axis] | None = None
161
+ ) -> dict[tuple[str, str, str], float]:
162
+ """The value each axis takes at a point, keyed by component, role and parameter."""
163
+ chosen = {}
164
+ for axis, index in zip(axes if axes is not None else self.axes, indices, strict=True):
165
+ parameter = {"scaleRadial": "scale_radial", "scaleHeight": "scale_height"}.get(
166
+ axis.kind, "optical_depth"
167
+ )
168
+ for role in axis.roles:
169
+ chosen[(axis.component, role, parameter)] = float(axis.values[index])
170
+ return chosen
171
+
172
+ def galaxy(
173
+ self, indices: Sequence[int], axes: Sequence[Axis] | None = None, emitter: str | None = None
174
+ ) -> Galaxy:
175
+ """The galaxy at a point on the parameter grid, as one emitter sees it.
176
+
177
+ A component which neither emits for this run nor carries dust cannot
178
+ change the answer, and is left out. That is not merely an economy: a
179
+ component included needlessly still sizes the grid, and a large spheroid
180
+ sizing the grid for a disk-only model is what leaves the disk
181
+ unresolved. The original arranged the same thing by hand, pinning the
182
+ spheroid scale to zero whenever the disk was emitting.
183
+ """
184
+ chosen = self._chosen(indices, axes)
185
+
186
+ def build(name: str, role: str, profile: ProfileConfig | None):
187
+ if profile is None:
188
+ return None
189
+ overrides = {
190
+ parameter: value
191
+ for (component, kind, parameter), value in chosen.items()
192
+ if component == name and kind == role and parameter in GEOMETRIC
193
+ }
194
+ return profile.build(**overrides)
195
+
196
+ components = []
197
+ for name, component in self.config.geometry.components.items():
198
+ emits = emitter is None or name == emitter
199
+ if not emits and component.dust is None:
200
+ continue
201
+ components.append(
202
+ Component(
203
+ name,
204
+ stellar=build(name, "stellar", component.stellar) if emits else None,
205
+ dust=build(name, "dust", component.dust),
206
+ )
207
+ )
208
+ return Galaxy(components)
209
+
210
+ def optical_depths(self, indices: Sequence[int], axes: Sequence[Axis] | None = None) -> dict[str, float]:
211
+ """The optical depth of each dust-bearing component at a point."""
212
+ chosen = self._chosen(indices, axes)
213
+ return {
214
+ component: value
215
+ for (component, _role, parameter), value in chosen.items()
216
+ if parameter == "optical_depth"
217
+ }
218
+
219
+ def runs(self) -> Iterator[Run]:
220
+ """Every model in the campaign, in a stable order.
221
+
222
+ The seed is decremented per model, as the original did, so that each gets
223
+ its own realization rather than repeating one.
224
+ """
225
+ tabulation = self.config.tabulation
226
+ seed = tabulation.seed
227
+ for emitter in self.emitters:
228
+ axes = self.axes_for(emitter)
229
+ for indices in product(*(range(len(axis)) for axis in axes)):
230
+ seed -= 1
231
+ suffix = "_".join(str(index) for index in indices)
232
+ yield Run(
233
+ emitter=emitter,
234
+ indices=indices,
235
+ label=f"{self.config.label}_{emitter}" + (f"_{suffix}" if suffix else ""),
236
+ spec=ModelSpec(
237
+ galaxy=self.galaxy(indices, axes, emitter),
238
+ emitter=emitter,
239
+ optical_depths=self.optical_depths(indices, axes),
240
+ wavelengths=self.wavelengths,
241
+ inclinations=self.inclinations,
242
+ cut_off=self.config.geometry.cut_off,
243
+ photons=tabulation.photons,
244
+ seed=seed,
245
+ ),
246
+ )
247
+
248
+ def grid_options(self) -> Mapping[str, object]:
249
+ """Grid options from the configuration, to pass when building a model."""
250
+ geometry = self.config.geometry
251
+ return {
252
+ "spacing": geometry.spacing,
253
+ "radial_cells": geometry.radial_cells,
254
+ "vertical_cells": geometry.vertical_cells,
255
+ }
256
+
257
+ def __repr__(self) -> str:
258
+ axes = ", ".join(f"{axis.name}[{len(axis)}]" for axis in self.axes)
259
+ return f"Campaign({self.config.label!r}, {len(self)} models, axes: {axes})"