diff --git a/.gitignore b/.gitignore index 4845230b..4178220d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,19 @@ -.idea -*__pycache__ +.idea/ +__pycache__/ +*.py[cod] logs/ plots_tests/ -*.egg-info -venv -build -dist -.tox -.cache -docs/_build +venv/ +.tox/ +.cache/ + +docs/_build/ +docs/_generated/ + +*.egg-info/ +.eggs/ +build/ +dist/ # Notebooks *.ipynb @@ -17,6 +22,7 @@ notebooks/ notebooks/plots/*.png notebooks/plots/*.pdf notebooks/logs/ -docs/_generated/ + unused/ src/lfkit/_version.py +.DS_Store \ No newline at end of file diff --git a/docs/about/photometry_overview.rst b/docs/about/photometry_overview.rst index 7d680bb4..91557a07 100644 --- a/docs/about/photometry_overview.rst +++ b/docs/about/photometry_overview.rst @@ -40,7 +40,7 @@ distance, and sometimes additional photometric corrections. For worked examples of the LFKit public API, see the dedicated example pages: -- :doc:`../examples/luminosity_function_models` +- :doc:`../examples/lf_models/index` - :doc:`../examples/magnitudes_and_luminosities` - :doc:`../examples/magnitude_integrals` - :doc:`../examples/redshift_density` diff --git a/docs/api/index.rst b/docs/api/index.rst index a3097c64..fd4af68f 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -1,7 +1,16 @@ -API Reference +API reference ============= -.. toctree:: - :maxdepth: 2 +.. autosummary:: + :toctree: + :recursive: - lfkit \ No newline at end of file + lfkit + lfkit.api + lfkit.api.luminosity_function + lfkit.api.conditional_luminosity_function + lfkit.api.corrections + lfkit.luminosity_functions + lfkit.photometry + lfkit.corrections + lfkit.cosmo \ No newline at end of file diff --git a/docs/api/lfkit.api.conditional_luminosity_function.rst b/docs/api/lfkit.api.conditional_luminosity_function.rst index 9512c147..5f3c06a4 100644 --- a/docs/api/lfkit.api.conditional_luminosity_function.rst +++ b/docs/api/lfkit.api.conditional_luminosity_function.rst @@ -1,7 +1,12 @@ -lfkit.api.conditional\_luminosity\_function module -================================================== +lfkit.api.conditional\_luminosity\_function +=========================================== .. automodule:: lfkit.api.conditional_luminosity_function - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Classes + + .. autosummary:: + + ConditionalLuminosityFunction + \ No newline at end of file diff --git a/docs/api/lfkit.api.corrections.rst b/docs/api/lfkit.api.corrections.rst index 1e9dabf3..e1319fac 100644 --- a/docs/api/lfkit.api.corrections.rst +++ b/docs/api/lfkit.api.corrections.rst @@ -1,7 +1,12 @@ -lfkit.api.corrections module -============================ +lfkit.api.corrections +===================== .. automodule:: lfkit.api.corrections - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Classes + + .. autosummary:: + + Corrections + \ No newline at end of file diff --git a/docs/api/lfkit.api.luminosity_function.rst b/docs/api/lfkit.api.luminosity_function.rst index 12b0e08a..542b1bb5 100644 --- a/docs/api/lfkit.api.luminosity_function.rst +++ b/docs/api/lfkit.api.luminosity_function.rst @@ -1,7 +1,12 @@ -lfkit.api.luminosity\_function module -===================================== +lfkit.api.luminosity\_function +============================== .. automodule:: lfkit.api.luminosity_function - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Classes + + .. autosummary:: + + LuminosityFunction + \ No newline at end of file diff --git a/docs/api/lfkit.api.rst b/docs/api/lfkit.api.rst index fa062949..002dfc8a 100644 --- a/docs/api/lfkit.api.rst +++ b/docs/api/lfkit.api.rst @@ -1,20 +1,15 @@ -lfkit.api package -================= +lfkit.api +========= -Submodules ----------- - -.. toctree:: - :maxdepth: 2 +.. automodule:: lfkit.api - lfkit.api.conditional_luminosity_function - lfkit.api.corrections - lfkit.api.luminosity_function + +.. rubric:: Modules -Module contents ---------------- +.. autosummary:: + :toctree: + :recursive: -.. automodule:: lfkit.api - :members: - :show-inheritance: - :undoc-members: + conditional_luminosity_function + corrections + luminosity_function diff --git a/docs/api/lfkit.corrections.color_anchors.rst b/docs/api/lfkit.corrections.color_anchors.rst index 6b62ffca..891f0865 100644 --- a/docs/api/lfkit.corrections.color_anchors.rst +++ b/docs/api/lfkit.corrections.color_anchors.rst @@ -1,7 +1,12 @@ -lfkit.corrections.color\_anchors module -======================================= +lfkit.corrections.color\_anchors +================================ .. automodule:: lfkit.corrections.color_anchors - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + fit_coeffs_from_bandcolor + \ No newline at end of file diff --git a/docs/api/lfkit.corrections.filters.rst b/docs/api/lfkit.corrections.filters.rst index db9de416..d1d70450 100644 --- a/docs/api/lfkit.corrections.filters.rst +++ b/docs/api/lfkit.corrections.filters.rst @@ -1,7 +1,17 @@ -lfkit.corrections.filters module -================================ +lfkit.corrections.filters +========================= .. automodule:: lfkit.corrections.filters - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + list_supported + make_response_map + normalize_band + normalize_filterset + resolve_response_name + validate_coverage + \ No newline at end of file diff --git a/docs/api/lfkit.corrections.kcorrect_backend.rst b/docs/api/lfkit.corrections.kcorrect_backend.rst index 8df9337d..9a8c0a6e 100644 --- a/docs/api/lfkit.corrections.kcorrect_backend.rst +++ b/docs/api/lfkit.corrections.kcorrect_backend.rst @@ -1,7 +1,12 @@ -lfkit.corrections.kcorrect\_backend module -========================================== +lfkit.corrections.kcorrect\_backend +=================================== .. automodule:: lfkit.corrections.kcorrect_backend - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + build_kcorrect + \ No newline at end of file diff --git a/docs/api/lfkit.corrections.kcorrect_from_color.rst b/docs/api/lfkit.corrections.kcorrect_from_color.rst index e136c6e2..43531a9f 100644 --- a/docs/api/lfkit.corrections.kcorrect_from_color.rst +++ b/docs/api/lfkit.corrections.kcorrect_from_color.rst @@ -1,7 +1,12 @@ -lfkit.corrections.kcorrect\_from\_color module -============================================== +lfkit.corrections.kcorrect\_from\_color +======================================= .. automodule:: lfkit.corrections.kcorrect_from_color - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + kcorrect_from_bandcolor + \ No newline at end of file diff --git a/docs/api/lfkit.corrections.kcorrect_grids.rst b/docs/api/lfkit.corrections.kcorrect_grids.rst index 64c79843..1e5e7ca8 100644 --- a/docs/api/lfkit.corrections.kcorrect_grids.rst +++ b/docs/api/lfkit.corrections.kcorrect_grids.rst @@ -1,7 +1,14 @@ -lfkit.corrections.kcorrect\_grids module -======================================== +lfkit.corrections.kcorrect\_grids +================================= .. automodule:: lfkit.corrections.kcorrect_grids - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + build_kcorr_grid_package + compute_k_table + kcorr_interpolators + \ No newline at end of file diff --git a/docs/api/lfkit.corrections.poggianti1997.rst b/docs/api/lfkit.corrections.poggianti1997.rst index de21eef0..5d77e221 100644 --- a/docs/api/lfkit.corrections.poggianti1997.rst +++ b/docs/api/lfkit.corrections.poggianti1997.rst @@ -1,7 +1,21 @@ -lfkit.corrections.poggianti1997 module -====================================== +lfkit.corrections.poggianti1997 +=============================== .. automodule:: lfkit.corrections.poggianti1997 - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + available_pairs + describe_poggianti1997_available + extract_sed_spectrum + load_poggianti1997_tables + make_ecorr_interpolator + make_kcorr_interpolator + poggianti1997_lookback_time_gyr + poggianti1997_time_since_bb_gyr + poggianti1997_to_accelerating_redshift + z_from_lookback_time + \ No newline at end of file diff --git a/docs/api/lfkit.corrections.responses.rst b/docs/api/lfkit.corrections.responses.rst index 71bf4f42..93fe638a 100644 --- a/docs/api/lfkit.corrections.responses.rst +++ b/docs/api/lfkit.corrections.responses.rst @@ -1,7 +1,16 @@ -lfkit.corrections.responses module -================================== +lfkit.corrections.responses +=========================== .. automodule:: lfkit.corrections.responses - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + discover_response_dir_auto + kcorrect_supports_response_dir + list_available_responses + require_responses + write_kcorrect_response + \ No newline at end of file diff --git a/docs/api/lfkit.corrections.rst b/docs/api/lfkit.corrections.rst index 93cde578..cd579d56 100644 --- a/docs/api/lfkit.corrections.rst +++ b/docs/api/lfkit.corrections.rst @@ -1,24 +1,19 @@ -lfkit.corrections package -========================= +lfkit.corrections +================= -Submodules ----------- - -.. toctree:: - :maxdepth: 2 +.. automodule:: lfkit.corrections - lfkit.corrections.color_anchors - lfkit.corrections.filters - lfkit.corrections.kcorrect_backend - lfkit.corrections.kcorrect_from_color - lfkit.corrections.kcorrect_grids - lfkit.corrections.poggianti1997 - lfkit.corrections.responses + +.. rubric:: Modules -Module contents ---------------- +.. autosummary:: + :toctree: + :recursive: -.. automodule:: lfkit.corrections - :members: - :show-inheritance: - :undoc-members: + color_anchors + filters + kcorrect_backend + kcorrect_from_color + kcorrect_grids + poggianti1997 + responses diff --git a/docs/api/lfkit.cosmo.cosmology.rst b/docs/api/lfkit.cosmo.cosmology.rst index 8a766ae2..d544eef4 100644 --- a/docs/api/lfkit.cosmo.cosmology.rst +++ b/docs/api/lfkit.cosmo.cosmology.rst @@ -1,7 +1,17 @@ -lfkit.cosmo.cosmology module -============================ +lfkit.cosmo.cosmology +===================== .. automodule:: lfkit.cosmo.cosmology - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + comoving_distance_mpc + cosmo_object + differential_comoving_volume + distance_modulus + lookback_time_gyr + luminosity_distance_mpc + \ No newline at end of file diff --git a/docs/api/lfkit.cosmo.rst b/docs/api/lfkit.cosmo.rst index 8836025b..e731c99c 100644 --- a/docs/api/lfkit.cosmo.rst +++ b/docs/api/lfkit.cosmo.rst @@ -1,18 +1,13 @@ -lfkit.cosmo package -=================== +lfkit.cosmo +=========== -Submodules ----------- - -.. toctree:: - :maxdepth: 2 +.. automodule:: lfkit.cosmo - lfkit.cosmo.cosmology + +.. rubric:: Modules -Module contents ---------------- +.. autosummary:: + :toctree: + :recursive: -.. automodule:: lfkit.cosmo - :members: - :show-inheritance: - :undoc-members: + cosmology diff --git a/docs/api/lfkit.luminosity_functions.completeness.rst b/docs/api/lfkit.luminosity_functions.completeness.rst new file mode 100644 index 00000000..73fa550b --- /dev/null +++ b/docs/api/lfkit.luminosity_functions.completeness.rst @@ -0,0 +1,16 @@ +lfkit.luminosity\_functions.completeness +======================================== + +.. automodule:: lfkit.luminosity_functions.completeness + + + .. rubric:: Functions + + .. autosummary:: + + absolute_magnitude_limit + catalog_fraction + missing_number_density + observed_number_density + out_of_catalog_fraction + \ No newline at end of file diff --git a/docs/api/lfkit.luminosity_functions.conditional_integrals.rst b/docs/api/lfkit.luminosity_functions.conditional_integrals.rst new file mode 100644 index 00000000..365e4c4d --- /dev/null +++ b/docs/api/lfkit.luminosity_functions.conditional_integrals.rst @@ -0,0 +1,14 @@ +lfkit.luminosity\_functions.conditional\_integrals +================================================== + +.. automodule:: lfkit.luminosity_functions.conditional_integrals + + + .. rubric:: Functions + + .. autosummary:: + + evaluate_conditional_luminosity_function + integrate_conditional_luminosity_function + integrate_weighted_conditional_luminosity_function + \ No newline at end of file diff --git a/docs/api/lfkit.luminosity_functions.conditional_models.rst b/docs/api/lfkit.luminosity_functions.conditional_models.rst new file mode 100644 index 00000000..a0dd91e9 --- /dev/null +++ b/docs/api/lfkit.luminosity_functions.conditional_models.rst @@ -0,0 +1,12 @@ +lfkit.luminosity\_functions.conditional\_models +=============================================== + +.. automodule:: lfkit.luminosity_functions.conditional_models + + + .. rubric:: Functions + + .. autosummary:: + + conditionalize_lf_model + \ No newline at end of file diff --git a/docs/api/lfkit.luminosity_functions.integrals.rst b/docs/api/lfkit.luminosity_functions.integrals.rst new file mode 100644 index 00000000..f87da20c --- /dev/null +++ b/docs/api/lfkit.luminosity_functions.integrals.rst @@ -0,0 +1,21 @@ +lfkit.luminosity\_functions.integrals +===================================== + +.. automodule:: lfkit.luminosity_functions.integrals + + + .. rubric:: Functions + + .. autosummary:: + + cumulative_number_density + cumulative_selection_function + integrated_luminosity_density + integrated_number_density + lf_weighted_integral + luminosity_weight + magnitude_window_number_density + mean_luminosity + selection_fraction + selection_weighted_number_density + \ No newline at end of file diff --git a/docs/api/lfkit.luminosity_functions.models.composite.rst b/docs/api/lfkit.luminosity_functions.models.composite.rst new file mode 100644 index 00000000..e2694b06 --- /dev/null +++ b/docs/api/lfkit.luminosity_functions.models.composite.rst @@ -0,0 +1,13 @@ +lfkit.luminosity\_functions.models.composite +============================================ + +.. automodule:: lfkit.luminosity_functions.models.composite + + + .. rubric:: Functions + + .. autosummary:: + + additive_lf + two_component_lf + \ No newline at end of file diff --git a/docs/api/lfkit.luminosity_functions.models.gaussian.rst b/docs/api/lfkit.luminosity_functions.models.gaussian.rst new file mode 100644 index 00000000..a7f3b86a --- /dev/null +++ b/docs/api/lfkit.luminosity_functions.models.gaussian.rst @@ -0,0 +1,13 @@ +lfkit.luminosity\_functions.models.gaussian +=========================================== + +.. automodule:: lfkit.luminosity_functions.models.gaussian + + + .. rubric:: Functions + + .. autosummary:: + + gaussian_lf + lognormal_lf + \ No newline at end of file diff --git a/docs/api/lfkit.luminosity_functions.models.modifiers.rst b/docs/api/lfkit.luminosity_functions.models.modifiers.rst new file mode 100644 index 00000000..ed38ada8 --- /dev/null +++ b/docs/api/lfkit.luminosity_functions.models.modifiers.rst @@ -0,0 +1,12 @@ +lfkit.luminosity\_functions.models.modifiers +============================================ + +.. automodule:: lfkit.luminosity_functions.models.modifiers + + + .. rubric:: Functions + + .. autosummary:: + + apply_luminosity_cutoff + \ No newline at end of file diff --git a/docs/api/lfkit.luminosity_functions.models.power_law.rst b/docs/api/lfkit.luminosity_functions.models.power_law.rst new file mode 100644 index 00000000..279379ff --- /dev/null +++ b/docs/api/lfkit.luminosity_functions.models.power_law.rst @@ -0,0 +1,15 @@ +lfkit.luminosity\_functions.models.power\_law +============================================= + +.. automodule:: lfkit.luminosity_functions.models.power_law + + + .. rubric:: Functions + + .. autosummary:: + + broken_power_law_lf + double_power_law_lf + log_power_law_lf + power_law_lf + \ No newline at end of file diff --git a/docs/api/lfkit.luminosity_functions.models.rst b/docs/api/lfkit.luminosity_functions.models.rst new file mode 100644 index 00000000..7be2db6f --- /dev/null +++ b/docs/api/lfkit.luminosity_functions.models.rst @@ -0,0 +1,17 @@ +lfkit.luminosity\_functions.models +================================== + +.. automodule:: lfkit.luminosity_functions.models + + +.. rubric:: Modules + +.. autosummary:: + :toctree: + :recursive: + + composite + gaussian + modifiers + power_law + schechter diff --git a/docs/api/lfkit.luminosity_functions.models.schechter.rst b/docs/api/lfkit.luminosity_functions.models.schechter.rst new file mode 100644 index 00000000..85fd5026 --- /dev/null +++ b/docs/api/lfkit.luminosity_functions.models.schechter.rst @@ -0,0 +1,19 @@ +lfkit.luminosity\_functions.models.schechter +============================================ + +.. automodule:: lfkit.luminosity_functions.models.schechter + + + .. rubric:: Functions + + .. autosummary:: + + double_schechter + double_schechter_from_m + evolving_schechter + evolving_schechter_from_m + schechter + schechter_cumulative + schechter_cumulative_evolving + schechter_from_m + \ No newline at end of file diff --git a/docs/api/lfkit.luminosity_functions.parameter_models.rst b/docs/api/lfkit.luminosity_functions.parameter_models.rst new file mode 100644 index 00000000..c4c9c60d --- /dev/null +++ b/docs/api/lfkit.luminosity_functions.parameter_models.rst @@ -0,0 +1,23 @@ +lfkit.luminosity\_functions.parameter\_models +============================================= + +.. automodule:: lfkit.luminosity_functions.parameter_models + + + .. rubric:: Functions + + .. autosummary:: + + alpha_constant + alpha_linear + available_lf_parameter_models + evaluate_lf_parameters + get_parameter_model + m_star_constant + m_star_linear_q + phi_star_constant + phi_star_linear_p + register_alpha_model + register_m_star_model + register_phi_star_model + \ No newline at end of file diff --git a/docs/api/lfkit.luminosity_functions.redshift_density.rst b/docs/api/lfkit.luminosity_functions.redshift_density.rst new file mode 100644 index 00000000..892a0634 --- /dev/null +++ b/docs/api/lfkit.luminosity_functions.redshift_density.rst @@ -0,0 +1,13 @@ +lfkit.luminosity\_functions.redshift\_density +============================================= + +.. automodule:: lfkit.luminosity_functions.redshift_density + + + .. rubric:: Functions + + .. autosummary:: + + lf_integrated_number_density + lf_weighted_redshift_density + \ No newline at end of file diff --git a/docs/api/lfkit.luminosity_functions.registry.rst b/docs/api/lfkit.luminosity_functions.registry.rst new file mode 100644 index 00000000..b2e394e0 --- /dev/null +++ b/docs/api/lfkit.luminosity_functions.registry.rst @@ -0,0 +1,24 @@ +lfkit.luminosity\_functions.registry +==================================== + +.. automodule:: lfkit.luminosity_functions.registry + + + .. rubric:: Functions + + .. autosummary:: + + available_conditional_lf_models + available_lf_from_m_models + available_lf_models + discover_lf_models + get_conditional_lf_model + get_lf_from_m_model + get_lf_model + + .. rubric:: Classes + + .. autosummary:: + + LFModel + \ No newline at end of file diff --git a/docs/api/lfkit.luminosity_functions.rst b/docs/api/lfkit.luminosity_functions.rst new file mode 100644 index 00000000..a8449821 --- /dev/null +++ b/docs/api/lfkit.luminosity_functions.rst @@ -0,0 +1,20 @@ +lfkit.luminosity\_functions +=========================== + +.. automodule:: lfkit.luminosity_functions + + +.. rubric:: Modules + +.. autosummary:: + :toctree: + :recursive: + + completeness + conditional_integrals + conditional_models + integrals + models + parameter_models + redshift_density + registry diff --git a/docs/api/lfkit.photometry.catalog_completeness.rst b/docs/api/lfkit.photometry.catalog_completeness.rst deleted file mode 100644 index 791e4592..00000000 --- a/docs/api/lfkit.photometry.catalog_completeness.rst +++ /dev/null @@ -1,7 +0,0 @@ -lfkit.photometry.catalog\_completeness module -============================================= - -.. automodule:: lfkit.photometry.catalog_completeness - :members: - :show-inheritance: - :undoc-members: diff --git a/docs/api/lfkit.photometry.conditional_lf_integrals.rst b/docs/api/lfkit.photometry.conditional_lf_integrals.rst deleted file mode 100644 index 1912c316..00000000 --- a/docs/api/lfkit.photometry.conditional_lf_integrals.rst +++ /dev/null @@ -1,7 +0,0 @@ -lfkit.photometry.conditional\_lf\_integrals module -================================================== - -.. automodule:: lfkit.photometry.conditional_lf_integrals - :members: - :show-inheritance: - :undoc-members: diff --git a/docs/api/lfkit.photometry.conditional_lf_models.rst b/docs/api/lfkit.photometry.conditional_lf_models.rst deleted file mode 100644 index 13f14a08..00000000 --- a/docs/api/lfkit.photometry.conditional_lf_models.rst +++ /dev/null @@ -1,7 +0,0 @@ -lfkit.photometry.conditional\_lf\_models module -=============================================== - -.. automodule:: lfkit.photometry.conditional_lf_models - :members: - :show-inheritance: - :undoc-members: diff --git a/docs/api/lfkit.photometry.lf_integrals.rst b/docs/api/lfkit.photometry.lf_integrals.rst deleted file mode 100644 index 55f6f132..00000000 --- a/docs/api/lfkit.photometry.lf_integrals.rst +++ /dev/null @@ -1,7 +0,0 @@ -lfkit.photometry.lf\_integrals module -===================================== - -.. automodule:: lfkit.photometry.lf_integrals - :members: - :show-inheritance: - :undoc-members: diff --git a/docs/api/lfkit.photometry.lf_parameter_models.rst b/docs/api/lfkit.photometry.lf_parameter_models.rst deleted file mode 100644 index d79c240e..00000000 --- a/docs/api/lfkit.photometry.lf_parameter_models.rst +++ /dev/null @@ -1,7 +0,0 @@ -lfkit.photometry.lf\_parameter\_models module -============================================= - -.. automodule:: lfkit.photometry.lf_parameter_models - :members: - :show-inheritance: - :undoc-members: diff --git a/docs/api/lfkit.photometry.lf_redshift_density.rst b/docs/api/lfkit.photometry.lf_redshift_density.rst deleted file mode 100644 index 576d8abf..00000000 --- a/docs/api/lfkit.photometry.lf_redshift_density.rst +++ /dev/null @@ -1,7 +0,0 @@ -lfkit.photometry.lf\_redshift\_density module -============================================= - -.. automodule:: lfkit.photometry.lf_redshift_density - :members: - :show-inheritance: - :undoc-members: diff --git a/docs/api/lfkit.photometry.luminosities.rst b/docs/api/lfkit.photometry.luminosities.rst index a9f0b25a..25cd3bc4 100644 --- a/docs/api/lfkit.photometry.luminosities.rst +++ b/docs/api/lfkit.photometry.luminosities.rst @@ -1,7 +1,16 @@ -lfkit.photometry.luminosities module -==================================== +lfkit.photometry.luminosities +============================= .. automodule:: lfkit.photometry.luminosities - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + luminosity_from_magnitude + luminosity_ratio + luminosity_ratio_from_magnitudes + luminosity_weight_from_magnitude + magnitude_difference_from_luminosity_ratio + \ No newline at end of file diff --git a/docs/api/lfkit.photometry.luminosity_function.rst b/docs/api/lfkit.photometry.luminosity_function.rst deleted file mode 100644 index 29864622..00000000 --- a/docs/api/lfkit.photometry.luminosity_function.rst +++ /dev/null @@ -1,7 +0,0 @@ -lfkit.photometry.luminosity\_function module -============================================ - -.. automodule:: lfkit.photometry.luminosity_function - :members: - :show-inheritance: - :undoc-members: diff --git a/docs/api/lfkit.photometry.magnitudes.rst b/docs/api/lfkit.photometry.magnitudes.rst index 1260f003..022bbc7f 100644 --- a/docs/api/lfkit.photometry.magnitudes.rst +++ b/docs/api/lfkit.photometry.magnitudes.rst @@ -1,7 +1,16 @@ -lfkit.photometry.magnitudes module -================================== +lfkit.photometry.magnitudes +=========================== .. automodule:: lfkit.photometry.magnitudes - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + absolute_magnitude + absolute_magnitude_from_luminosity_distance + apparent_magnitude + apparent_magnitude_from_luminosity_distance + total_magnitude_correction + \ No newline at end of file diff --git a/docs/api/lfkit.photometry.rst b/docs/api/lfkit.photometry.rst index c7c6faea..60f5cd38 100644 --- a/docs/api/lfkit.photometry.rst +++ b/docs/api/lfkit.photometry.rst @@ -1,26 +1,14 @@ -lfkit.photometry package -======================== +lfkit.photometry +================ -Submodules ----------- - -.. toctree:: - :maxdepth: 2 +.. automodule:: lfkit.photometry - lfkit.photometry.catalog_completeness - lfkit.photometry.conditional_lf_integrals - lfkit.photometry.conditional_lf_models - lfkit.photometry.lf_integrals - lfkit.photometry.lf_parameter_models - lfkit.photometry.lf_redshift_density - lfkit.photometry.luminosities - lfkit.photometry.luminosity_function - lfkit.photometry.magnitudes + +.. rubric:: Modules -Module contents ---------------- +.. autosummary:: + :toctree: + :recursive: -.. automodule:: lfkit.photometry - :members: - :show-inheritance: - :undoc-members: + luminosities + magnitudes diff --git a/docs/api/lfkit.rst b/docs/api/lfkit.rst index 1afdeb09..1563afa1 100644 --- a/docs/api/lfkit.rst +++ b/docs/api/lfkit.rst @@ -1,22 +1,18 @@ -lfkit package -============= +lfkit +===== -Subpackages ------------ - -.. toctree:: - :maxdepth: 2 +.. automodule:: lfkit - lfkit.api - lfkit.corrections - lfkit.cosmo - lfkit.photometry - lfkit.utils + +.. rubric:: Modules -Module contents ---------------- +.. autosummary:: + :toctree: + :recursive: -.. automodule:: lfkit - :members: - :show-inheritance: - :undoc-members: + api + corrections + cosmo + luminosity_functions + photometry + utils diff --git a/docs/api/lfkit.utils.download_poggianti97_data.rst b/docs/api/lfkit.utils.download_poggianti97_data.rst index b4c6a97e..ae2f4b1c 100644 --- a/docs/api/lfkit.utils.download_poggianti97_data.rst +++ b/docs/api/lfkit.utils.download_poggianti97_data.rst @@ -1,7 +1,12 @@ -lfkit.utils.download\_poggianti97\_data module -============================================== +lfkit.utils.download\_poggianti97\_data +======================================= .. automodule:: lfkit.utils.download_poggianti97_data - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + main + \ No newline at end of file diff --git a/docs/api/lfkit.utils.evaluators.rst b/docs/api/lfkit.utils.evaluators.rst index 8e8ffa89..36f78e42 100644 --- a/docs/api/lfkit.utils.evaluators.rst +++ b/docs/api/lfkit.utils.evaluators.rst @@ -1,7 +1,16 @@ -lfkit.utils.evaluators module -============================= +lfkit.utils.evaluators +====================== .. automodule:: lfkit.utils.evaluators - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + evaluate_lf_on_grid + evaluate_non_negative_redshift_callable + evaluate_optional_redshift_callable + evaluate_positive_redshift_callable + evaluate_weight_on_grid + \ No newline at end of file diff --git a/docs/api/lfkit.utils.integrators.rst b/docs/api/lfkit.utils.integrators.rst index 11454f18..ed4effc6 100644 --- a/docs/api/lfkit.utils.integrators.rst +++ b/docs/api/lfkit.utils.integrators.rst @@ -1,7 +1,13 @@ -lfkit.utils.integrators module -============================== +lfkit.utils.integrators +======================= .. automodule:: lfkit.utils.integrators - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + integrate_between_variable_bounds + safe_divide + \ No newline at end of file diff --git a/docs/api/lfkit.utils.interpolation.rst b/docs/api/lfkit.utils.interpolation.rst index af65b61d..7387cb57 100644 --- a/docs/api/lfkit.utils.interpolation.rst +++ b/docs/api/lfkit.utils.interpolation.rst @@ -1,7 +1,15 @@ -lfkit.utils.interpolation module -================================ +lfkit.utils.interpolation +========================= .. automodule:: lfkit.utils.interpolation - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + as_1d_finite_grid + build_1d_interpolator + linear_interp_extrap + prep_strictly_increasing_xy + \ No newline at end of file diff --git a/docs/api/lfkit.utils.io.rst b/docs/api/lfkit.utils.io.rst index 80e1cb7e..0fe2d9bb 100644 --- a/docs/api/lfkit.utils.io.rst +++ b/docs/api/lfkit.utils.io.rst @@ -1,7 +1,18 @@ -lfkit.utils.io module -===================== +lfkit.utils.io +============== .. automodule:: lfkit.utils.io - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + available_from_table + available_pairs + extract_series + load_kcorr_package + load_vizier_csv + resolve_packaged_csv + save_kcorr_package + \ No newline at end of file diff --git a/docs/api/lfkit.utils.rst b/docs/api/lfkit.utils.rst index 854d50c4..9330b50b 100644 --- a/docs/api/lfkit.utils.rst +++ b/docs/api/lfkit.utils.rst @@ -1,25 +1,20 @@ -lfkit.utils package -=================== +lfkit.utils +=========== -Submodules ----------- - -.. toctree:: - :maxdepth: 2 +.. automodule:: lfkit.utils - lfkit.utils.download_poggianti97_data - lfkit.utils.evaluators - lfkit.utils.integrators - lfkit.utils.interpolation - lfkit.utils.io - lfkit.utils.types - lfkit.utils.units - lfkit.utils.validators + +.. rubric:: Modules -Module contents ---------------- +.. autosummary:: + :toctree: + :recursive: -.. automodule:: lfkit.utils - :members: - :show-inheritance: - :undoc-members: + download_poggianti97_data + evaluators + integrators + interpolation + io + types + units + validators diff --git a/docs/api/lfkit.utils.types.rst b/docs/api/lfkit.utils.types.rst index 6fbcd989..494d5733 100644 --- a/docs/api/lfkit.utils.types.rst +++ b/docs/api/lfkit.utils.types.rst @@ -1,7 +1,6 @@ -lfkit.utils.types module -======================== +lfkit.utils.types +================= .. automodule:: lfkit.utils.types - :members: - :show-inheritance: - :undoc-members: + + \ No newline at end of file diff --git a/docs/api/lfkit.utils.units.rst b/docs/api/lfkit.utils.units.rst index 5de39a23..b009dca8 100644 --- a/docs/api/lfkit.utils.units.rst +++ b/docs/api/lfkit.utils.units.rst @@ -1,7 +1,17 @@ -lfkit.utils.units module -======================== +lfkit.utils.units +================= .. automodule:: lfkit.utils.units - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + h0_km_s_mpc_to_gyr_inv + km_per_mpc + mag_to_maggies + magerr_to_ivar_maggies + maggies_to_mag + sec_per_gyr + \ No newline at end of file diff --git a/docs/api/lfkit.utils.validators.rst b/docs/api/lfkit.utils.validators.rst index 2642decd..e905ce79 100644 --- a/docs/api/lfkit.utils.validators.rst +++ b/docs/api/lfkit.utils.validators.rst @@ -1,7 +1,14 @@ -lfkit.utils.validators module -============================= +lfkit.utils.validators +====================== .. automodule:: lfkit.utils.validators - :members: - :show-inheritance: - :undoc-members: + + + .. rubric:: Functions + + .. autosummary:: + + validate_array + validate_luminosity_distance + validate_magnitude_range + \ No newline at end of file diff --git a/docs/examples/conditional_luminosity_function.rst b/docs/examples/conditional_luminosity_function.rst index 02b389f3..0a116ca1 100644 --- a/docs/examples/conditional_luminosity_function.rst +++ b/docs/examples/conditional_luminosity_function.rst @@ -25,9 +25,8 @@ mass, or another quantity. LFKit exposes conditional luminosity functions through :class:`lfkit.ConditionalLuminosityFunction`. Each constructor returns a -:class:`lfkit.LuminosityFunction` object, so the resulting model can be -evaluated with ``lf.phi`` and integrated with the usual ``lf.integrals`` -namespace. +luminosity-function object that can be evaluated with +``lf.phi`` and integrated through the usual ``lf.integrals`` namespace. The examples below use redshift as the conditioning variable because this is the most common use case for luminosity function evolution. In that case, @@ -38,9 +37,8 @@ variable by replacing ``z`` with the desired quantity. The examples include: * a conditional Schechter luminosity function, -* a conditional Schechter model using LFKit parameter models, +* a conditional Schechter model using callable parameter evolution, * a lognormal component, -* a modified Schechter-like component, * a two-component lognormal plus modified-Schechter model, * integrated number densities and component fractions, * a halo-mass conditional example, @@ -205,19 +203,21 @@ problem with the chosen parameterization or redshift dependence. plt.tight_layout() -Conditional Schechter model with LFKit parameter models -------------------------------------------------------- +Toy SFR-conditioned Schechter luminosity function +------------------------------------------------- -LFKit can also build conditional Schechter models from its registered parameter -models. This is useful when the desired evolution follows one of the standard -forms already available in LFKit, rather than being written manually as a -callable. +The conditioning variable does not have to be redshift. The same conditional +Schechter API can be used with any external galaxy or environment property. For +example, one can write a luminosity function conditioned on star-formation rate, -Here, the normalization and characteristic magnitude evolve with the -conditioning variable, while the faint-end slope is constant. The result is -equivalent in spirit to the previous example, but the parameter evolution is -defined through named LFKit models. This makes the model easier to reproduce, -configure, and document. +.. math:: + + \Phi(M \mid \mathrm{SFR}). + +In this case, each value of ``sfr`` selects a different Schechter luminosity +function. The example below is a toy model where galaxies with larger +star-formation rates have a brighter characteristic magnitude and a larger +normalization. .. plot:: :include-source: True @@ -235,35 +235,32 @@ configure, and document. LEGEND_SIZE = 15 absolute_mag = np.linspace(-24.0, -14.0, 500) - redshifts = [0.1, 0.6, 1.1] + sfr_values = [0.3, 1.0, 3.0, 10.0] colors = cmr.take_cmap_colors( "cmr.guppy", - len(redshifts), + len(sfr_values), cmap_range=(0.0, 0.2), ) - lf = ConditionalLuminosityFunction.evolving_schechter( - phi_model="linear_p", - phi_kwargs={"phi_0_star": 1.0e-3, "p": 0.7}, - m_star_model="linear_q", - m_star_kwargs={"m_0_star": -20.5, "q": 0.8, "z_ref": 0.1}, - alpha_model="constant", - alpha_kwargs={"alpha": -1.1}, + lf = ConditionalLuminosityFunction.schechter( + phi_star=lambda sfr: 8.0e-4 * (sfr / 1.0) ** 0.35, + m_star=lambda sfr: -20.2 - 0.7 * np.log10(sfr / 1.0), + alpha=lambda sfr: -1.05 - 0.08 * np.log10(sfr / 1.0), ) fig, ax = plt.subplots(figsize=(7.0, 5.0)) - for z_value, color in zip(redshifts, colors): - z = np.full_like(absolute_mag, z_value) - phi = lf.phi(absolute_mag, z) + for sfr_value, color in zip(sfr_values, colors): + sfr = np.full_like(absolute_mag, sfr_value) + phi = lf.phi(absolute_mag, sfr) ax.plot( absolute_mag, phi, lw=3, color=color, - label=rf"$z={z_value}$", + label=rf"$\mathrm{{SFR}}={sfr_value}\ M_\odot\,\mathrm{{yr}}^{{-1}}$", ) ax.set_yscale("log") @@ -271,10 +268,11 @@ configure, and document. ax.invert_xaxis() ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) ax.set_ylabel( - r"$\Phi(M \mid z)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", + r"$\Phi(M \mid \mathrm{SFR})$ " + r"[$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", fontsize=LABEL_SIZE, ) - ax.set_title("Conditional evolving Schechter model", fontsize=TITLE_SIZE) + ax.set_title("SFR-conditioned Schechter LF", fontsize=TITLE_SIZE) ax.tick_params(axis="both", labelsize=TICK_SIZE) ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") @@ -354,17 +352,16 @@ scale rather than a broad Schechter-like faint-end tail. plt.tight_layout() -Modified Schechter conditional component ----------------------------------------- +Double Schechter conditional component +-------------------------------------- -The modified Schechter component is Schechter-like at the faint end but uses a -different cutoff at high luminosity. Instead of the standard exponential cutoff, -it has a broader cutoff controlled by the luminosity ratio. +The double Schechter component combines two Schechter-like terms with a shared +characteristic magnitude. This gives more flexibility than a single Schechter +function because the two components can have different normalizations and +faint-end slopes. -This example lets the normalization, characteristic magnitude, and faint-end -slope vary with redshift. The model therefore changes both in amplitude and in -shape. This is useful when a standard Schechter function is too restrictive, -especially for populations whose bright end falls off more gradually. +This example lets both component normalizations and slopes vary with redshift. +The model therefore changes both in amplitude and in shape. .. plot:: :include-source: True @@ -390,10 +387,10 @@ especially for populations whose bright end falls off more gradually. cmap_range=(0.0, 0.2), ) - lf = ConditionalLuminosityFunction.modified_schechter( + lf = ConditionalLuminosityFunction.schechter( phi_star=lambda z: 1.2e-3 * (1.0 + z) ** 0.5, - m_star=lambda z: -19.9 - 0.5 * (z - 0.1), - alpha=lambda z: -1.05 - 0.10 * z, + m_star=lambda z: -20.3 - 0.5 * (z - 0.1), + alpha=lambda z: -1.15 - 0.10 * z, ) fig, ax = plt.subplots(figsize=(7.0, 5.0)) @@ -401,25 +398,18 @@ especially for populations whose bright end falls off more gradually. for z_value, color in zip(redshifts, colors): z = np.full_like(absolute_mag, z_value) phi = lf.phi(absolute_mag, z) - - ax.plot( - absolute_mag, - phi, - lw=3, - color=color, - label=rf"$z={z_value}$", - ) + ax.plot(absolute_mag, phi, lw=3, color=color, label=rf"$z={z_value}$") ax.set_yscale("log") ax.set_ylim(1.0e-8, 1.0e-1) ax.invert_xaxis() ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) ax.set_ylabel( - r"$\Phi_{\rm modSch}(M \mid z)$ " + r"$\Phi_{\rm double}(M \mid z)$ " r"[$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", fontsize=LABEL_SIZE, ) - ax.set_title("Modified Schechter conditional LF component", fontsize=TITLE_SIZE) + ax.set_title("Double Schechter conditional LF component", fontsize=TITLE_SIZE) ax.tick_params(axis="both", labelsize=TICK_SIZE) ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") @@ -468,10 +458,10 @@ best suited to describe. alpha=-1.1, ) - modified_lf = ConditionalLuminosityFunction.modified_schechter( + modified_lf = ConditionalLuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, - alpha=-1.1, + alpha=-1.25, ) lognormal_lf = ConditionalLuminosityFunction.lognormal( @@ -498,7 +488,7 @@ best suited to describe. phi_modified, lw=3, color=colors_red[1], - label="Modified Schechter", + label="Double Schechter", ) ax.plot( absolute_mag, @@ -525,18 +515,17 @@ best suited to describe. Two-component conditional LF ---------------------------------------------- +---------------------------- -The lognormal and modified Schechter components can be combined into a -two-component conditional luminosity function. This is useful when one component -is intended to describe a relatively narrow population and the other describes a -broader luminosity distribution. +The built-in two-component conditional luminosity function combines a +lognormal component with a broader Schechter-like component. This is useful +when one component is intended to describe a relatively narrow population and +the other describes a broader luminosity distribution. -This plot separates the lognormal component, the modified Schechter component, -and their sum at a fixed redshift. The comparison shows which component -dominates different magnitude ranges. In this example, the lognormal component -is concentrated near its characteristic magnitude, while the modified Schechter -component contributes over a wider range of magnitudes. +This plot compares the lognormal component with the full two-component model at +a fixed redshift. The comparison shows where the narrow component contributes +most strongly and where the full model receives additional contribution from +the broader component. .. plot:: :include-source: True @@ -554,7 +543,6 @@ component contributes over a wider range of magnitudes. LEGEND_SIZE = 15 colors_blue = cmr.take_cmap_colors("cmr.guppy", 3, cmap_range=(0.8, 1.0)) - colors_red = cmr.take_cmap_colors("cmr.guppy", 3, cmap_range=(0.0, 0.2)) colors_all = cmr.take_cmap_colors("cmr.guppy", 3, cmap_range=(0.0, 1.0)) absolute_mag = np.linspace(-24.0, -14.0, 500) @@ -567,12 +555,6 @@ component contributes over a wider range of magnitudes. amplitude=lambda z: 8.0e-4 * (1.0 + z) ** 0.4, ) - modified_lf = ConditionalLuminosityFunction.modified_schechter( - phi_star=lambda z: 1.2e-3 * (1.0 + z) ** 0.5, - m_star=lambda z: -19.9 - 0.5 * (z - 0.1), - alpha=lambda z: -1.05 - 0.10 * z, - ) - total_lf = ConditionalLuminosityFunction.two_component( lognormal_mean_absolute_mag=lambda z: -20.8 - 0.6 * (z - 0.1), lognormal_sigma_log_luminosity=0.18, @@ -583,7 +565,6 @@ component contributes over a wider range of magnitudes. ) lognormal_phi = lognormal_lf.phi(absolute_mag, z) - modified_phi = modified_lf.phi(absolute_mag, z) total_phi = total_lf.phi(absolute_mag, z) fig, ax = plt.subplots(figsize=(7.0, 5.0)) @@ -595,13 +576,6 @@ component contributes over a wider range of magnitudes. color=colors_blue[1], label="Lognormal component", ) - ax.plot( - absolute_mag, - modified_phi, - lw=3, - color=colors_red[1], - label="Modified Schechter component", - ) ax.plot( absolute_mag, total_phi, @@ -717,10 +691,9 @@ galaxies inside the chosen magnitude range as a function of redshift. Because conditional constructors return normal LFKit luminosity function objects, the same ``lf.integrals`` namespace can be used here. -This example integrates the lognormal component, the modified Schechter -component, and the full two-component model. The total number density is the sum -of the two components, while the individual curves show how much each component -contributes. +This example integrates the lognormal component and the full two-component +model. The gap between the curves shows the contribution from the broader +component in the two-component model. .. plot:: :include-source: True @@ -738,7 +711,6 @@ contributes. LEGEND_SIZE = 15 colors_blue = cmr.take_cmap_colors("cmr.guppy", 3, cmap_range=(0.8, 1.0)) - colors_red = cmr.take_cmap_colors("cmr.guppy", 3, cmap_range=(0.0, 0.2)) colors_all = cmr.take_cmap_colors("cmr.guppy", 3, cmap_range=(0.0, 1.0)) redshift = np.linspace(0.05, 1.5, 180) @@ -749,12 +721,6 @@ contributes. amplitude=lambda z: 8.0e-4 * (1.0 + z) ** 0.4, ) - modified_lf = ConditionalLuminosityFunction.modified_schechter( - phi_star=lambda z: 1.2e-3 * (1.0 + z) ** 0.5, - m_star=lambda z: -19.9 - 0.5 * (z - 0.1), - alpha=lambda z: -1.05 - 0.10 * z, - ) - total_lf = ConditionalLuminosityFunction.two_component( lognormal_mean_absolute_mag=lambda z: -20.8 - 0.6 * (z - 0.1), lognormal_sigma_log_luminosity=0.18, @@ -771,13 +737,6 @@ contributes. n_m=800, ) - n_modified = modified_lf.integrals.number_density( - redshift, - m_bright=-24.0, - m_faint=-14.0, - n_m=800, - ) - n_total = total_lf.integrals.number_density( redshift, m_bright=-24.0, @@ -794,13 +753,6 @@ contributes. color=colors_blue[1], label="Lognormal component", ) - ax.plot( - redshift, - n_modified, - lw=3, - color=colors_red[1], - label="Modified Schechter component", - ) ax.plot( redshift, n_total, @@ -823,20 +775,16 @@ contributes. Component fractions ------------------- -The relative contribution of each component can be summarized as a fraction of -the integrated two-component luminosity function. For components -:math:`n_1(z)` and :math:`n_2(z)`, the corresponding fractions are +The relative contribution of the lognormal component can be summarized as a +fraction of the integrated two-component luminosity function, .. math:: - f_1(z) = \frac{n_1(z)}{n_1(z) + n_2(z)}, \qquad - f_2(z) = \frac{n_2(z)}{n_1(z) + n_2(z)}. + f_{\rm lognormal}(z) = \frac{n_{\rm lognormal}(z)}{n_{\rm total}(z)}. -This example computes the integrated lognormal and modified Schechter -components with ``lf.integrals.number_density``. The resulting fractions show -whether the selected population is dominated by the narrow lognormal component, -the broader modified Schechter component, or a mixture of both. This is often -easier to interpret than comparing raw number densities alone. +The remaining fraction is the contribution from the broader component in the +two-component model. This is often easier to interpret than comparing raw +number densities alone. .. plot:: :include-source: True @@ -864,10 +812,13 @@ easier to interpret than comparing raw number densities alone. amplitude=lambda z: 8.0e-4 * (1.0 + z) ** 0.4, ) - modified_lf = ConditionalLuminosityFunction.modified_schechter( - phi_star=lambda z: 1.2e-3 * (1.0 + z) ** 0.5, - m_star=lambda z: -19.9 - 0.5 * (z - 0.1), - alpha=lambda z: -1.05 - 0.10 * z, + total_lf = ConditionalLuminosityFunction.two_component( + lognormal_mean_absolute_mag=lambda z: -20.8 - 0.6 * (z - 0.1), + lognormal_sigma_log_luminosity=0.18, + lognormal_amplitude=lambda z: 8.0e-4 * (1.0 + z) ** 0.4, + modified_phi_star=lambda z: 1.2e-3 * (1.0 + z) ** 0.5, + modified_m_star=lambda z: -19.9 - 0.5 * (z - 0.1), + modified_alpha=lambda z: -1.05 - 0.10 * z, ) n_lognormal = lognormal_lf.integrals.number_density( @@ -877,17 +828,15 @@ easier to interpret than comparing raw number densities alone. n_m=800, ) - n_modified = modified_lf.integrals.number_density( + n_total = total_lf.integrals.number_density( redshift, m_bright=-24.0, m_faint=-14.0, n_m=800, ) - n_total = n_lognormal + n_modified - lognormal_fraction = n_lognormal / n_total - modified_fraction = n_modified / n_total + broad_fraction = 1.0 - lognormal_fraction fig, ax = plt.subplots(figsize=(7.0, 5.0)) @@ -900,10 +849,10 @@ easier to interpret than comparing raw number densities alone. ) ax.plot( redshift, - modified_fraction, + broad_fraction, lw=3, color=colors_red[1], - label="Modified Schechter fraction", + label="Broad component fraction", ) ax.set_ylim(-0.05, 1.05) diff --git a/docs/examples/index.rst b/docs/examples/index.rst index 1485fca1..ccc55683 100644 --- a/docs/examples/index.rst +++ b/docs/examples/index.rst @@ -18,13 +18,12 @@ redshift-density trends, and magnitude or luminosity conversions. :maxdepth: 1 :hidden: - luminosity_function_models + lf_models/index + conditional_luminosity_function magnitude_integrals magnitudes_and_luminosities redshift_density catalog_completeness - conditional_luminosity_function - model_registry kcorrect_examples poggianti_examples @@ -60,6 +59,55 @@ Photometric corrections are exposed separately through :class:`lfkit.Corrections Correction functions can be passed into calculations that need k-corrections, evolution corrections, or other magnitude corrections. +Registered model constructors +----------------------------- + +LFKit model constructors are generated from the model registry. This means that +ordinary luminosity functions and conditional luminosity functions use the same +registered model names whenever a model supports both APIs. + +For example, an ordinary Schechter luminosity function is built with +:class:`lfkit.LuminosityFunction`: + +.. code-block:: python + + from lfkit import LuminosityFunction + + lf = LuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + ) + + phi = lf.phi(absolute_mag) + +The conditional version uses the same registered model name through +:class:`lfkit.ConditionalLuminosityFunction`: + +.. code-block:: python + + from lfkit import ConditionalLuminosityFunction + + clf = ConditionalLuminosityFunction.schechter( + phi_star=lambda condition: 1.0e-3 * (1.0 + condition) ** 0.5, + m_star=lambda condition: -20.5 - 0.4 * condition, + alpha=-1.1, + ) + + phi = clf.phi(absolute_mag, condition) + +In the conditional case, the second argument to ``phi`` is the conditioning +variable. It can represent redshift, halo mass, stellar mass, SFR, environment, +richness, or any other quantity chosen by the user. Callable parameters are +evaluated at that condition, while non-callable parameters are kept fixed. + +Available registered models can be inspected directly: + +.. code-block:: python + + LuminosityFunction.available_models() + ConditionalLuminosityFunction.available_models() + Main API areas -------------- @@ -88,7 +136,7 @@ Which tool do I need? - Example page * - Evaluate or compare luminosity function models - :class:`lfkit.LuminosityFunction` - - :doc:`luminosity function models ` + - :doc:`luminosity function models ` * - Integrate a luminosity function over absolute magnitude - ``lf.integrals`` - :doc:`magnitude_integrals` @@ -115,7 +163,7 @@ Which tool do I need? - :doc:`poggianti_examples` * - Inspect available model names - ``available_models()`` methods - - :doc:`model_registry` + - :doc:`lf_models/model_registry` Basic workflow @@ -205,7 +253,7 @@ The examples are split by topic so that each page stays focused. :gutter: 2 .. grid-item-card:: - :link: luminosity_function_models + :link: lf_models/index :link-type: doc :shadow: md @@ -283,7 +331,7 @@ The examples are split by topic so that each page stays focused. *API:* ``ConditionalLuminosityFunction`` .. grid-item-card:: - :link: model_registry + :link: lf_models/model_registry :link-type: doc :shadow: md @@ -348,7 +396,7 @@ For an evolving model, pass redshift when evaluating the model: The luminosity function examples page shows how to compare models, evaluate evolving parameters, and visualize luminosity function behavior: -:doc:`luminosity function models ` +:doc:`luminosity function models ` Magnitude integrals @@ -559,7 +607,7 @@ For conditional luminosity functions: See the dedicated page for complete examples: -:doc:`model_registry` +:doc:`lf_models/model_registry` Next steps diff --git a/docs/examples/lf_models/composite_models.rst b/docs/examples/lf_models/composite_models.rst new file mode 100644 index 00000000..1d450a4e --- /dev/null +++ b/docs/examples/lf_models/composite_models.rst @@ -0,0 +1,171 @@ +.. |lfkitlogo| image:: /_static/logos/lfkit_logo-icon.png + :alt: LFKit logo + :width: 50px + +|lfkitlogo| Composite models +============================= + +Composite models combine multiple luminosity-function components. They are +useful when a population is better described as a mixture rather than by one +single analytic shape. + +Two-component luminosity function +--------------------------------- + +The two-component model combines a lognormal component with a cutoff-modified +Schechter component. The lognormal component describes a localized population, +while the modified Schechter component contributes a broader luminosity +function with a suppressed bright end. + +.. plot:: + :include-source: True + :width: 520 + + import numpy as np + import matplotlib.pyplot as plt + import cmasher as cmr + + from lfkit import LuminosityFunction + + LABEL_SIZE = 15 + TICK_SIZE = 13 + TITLE_SIZE = 17 + LEGEND_SIZE = 15 + + lognormal = LuminosityFunction.lognormal( + mean_absolute_mag=-21.0, + sigma_log_luminosity=0.10, + amplitude=6.0e-4, + ) + + composite = LuminosityFunction.two_component( + lognormal_mean_absolute_mag=-21.0, + lognormal_sigma_log_luminosity=0.25, + lognormal_amplitude=6.0e-4, + modified_phi_star=1.0e-3, + modified_alpha=-1.1, + modified_luminosity_fraction=0.562, + ) + + absolute_mag = np.linspace(-24.0, -14.0, 500) + colors = cmr.take_cmap_colors("cmr.guppy", 2, cmap_range=(0.0, 0.2)) + + fig, ax = plt.subplots(figsize=(7.0, 5.0)) + ax.plot( + absolute_mag, + lognormal.phi(absolute_mag), + lw=3, + color=colors[0], + label="Lognormal component", + ) + ax.plot( + absolute_mag, + composite.phi(absolute_mag), + lw=3, + color=colors[1], + label="Two-component model", + ) + + ax.set_yscale("log") + ax.set_ylim(1e-8, 1e-1) + ax.invert_xaxis() + ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) + ax.set_ylabel( + r"$\Phi(M)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", + fontsize=LABEL_SIZE, + ) + ax.set_title("Composite luminosity function", fontsize=TITLE_SIZE) + ax.tick_params(axis="both", labelsize=TICK_SIZE) + ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") + plt.tight_layout() + + +Luminosity cutoff modifier +-------------------------- + +A luminosity cutoff can be applied to an existing luminosity-function object. +This keeps the base model unchanged and returns a new object whose +:meth:`lfkit.LuminosityFunction.phi` values are multiplied by a bright-end +cutoff. + +.. plot:: + :include-source: True + :width: 520 + + import numpy as np + import matplotlib.pyplot as plt + import cmasher as cmr + + from lfkit import LuminosityFunction + + LABEL_SIZE = 15 + TICK_SIZE = 13 + TITLE_SIZE = 17 + LEGEND_SIZE = 14 + + absolute_mag = np.linspace(-24.0, -14.0, 500) + + models = { + "Schechter": LuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + ), + "Double Schechter": LuminosityFunction.double_schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + beta=-0.6, + m_transition=-18.5, + ), + "Double power law": LuminosityFunction.double_power_law( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.4, + beta=-0.4, + ), + } + + colors = cmr.take_cmap_colors( + "cmr.guppy", + len(models), + cmap_range=(0.0, 0.35), + ) + + fig, ax = plt.subplots(figsize=(7.0, 5.0)) + + for (label, base), color in zip(models.items(), colors): + cutoff = base.with_luminosity_cutoff( + m_star=-20.5, + cutoff_power=1.0, + cutoff_amplitude=0.08, + ) + + ax.plot( + absolute_mag, + base.phi(absolute_mag), + lw=2.5, + ls="--", + color=color, + label=f"{label}", + ) + ax.plot( + absolute_mag, + cutoff.phi(absolute_mag), + lw=3, + color=color, + label=f"{label} + cutoff", + ) + + ax.set_yscale("log") + ax.set_ylim(1e-8, 1e-1) + ax.invert_xaxis() + ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) + ax.set_ylabel( + r"$\Phi(M)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", + fontsize=LABEL_SIZE, + ) + ax.set_title("Applying one luminosity cutoff to multiple LF models", fontsize=TITLE_SIZE) + ax.tick_params(axis="both", labelsize=TICK_SIZE) + ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") + plt.tight_layout() diff --git a/docs/examples/lf_models/gaussian_models.rst b/docs/examples/lf_models/gaussian_models.rst new file mode 100644 index 00000000..c7aaef37 --- /dev/null +++ b/docs/examples/lf_models/gaussian_models.rst @@ -0,0 +1,130 @@ +.. |lfkitlogo| image:: /_static/logos/lfkit_logo-icon.png + :alt: LFKit logo + :width: 50px + +|lfkitlogo| Gaussian-like models +================================ + +Gaussian-like models describe localized luminosity populations with a peak and +a width. They are useful for toy models, narrow galaxy populations, or as +components in composite luminosity functions. + +Gaussian luminosity function +---------------------------- + +The Gaussian model is defined directly in magnitude space. The parameter +``mean_absolute_mag`` controls the peak location, ``sigma_absolute_mag`` +controls the width, and ``amplitude`` controls the total normalization. + +.. plot:: + :include-source: True + :width: 520 + + import numpy as np + import matplotlib.pyplot as plt + import cmasher as cmr + + from lfkit import LuminosityFunction + + LABEL_SIZE = 15 + TICK_SIZE = 13 + TITLE_SIZE = 17 + LEGEND_SIZE = 15 + + absolute_mag = np.linspace(-24.0, -14.0, 500) + sigmas = [0.4, 0.8, 1.2] + colors = cmr.take_cmap_colors("cmr.guppy", len(sigmas), cmap_range=(0.0, 0.2)) + + fig, ax = plt.subplots(figsize=(7.0, 5.0)) + + for sigma, color in zip(sigmas, colors): + lf = LuminosityFunction.gaussian( + mean_absolute_mag=-20.5, + sigma_absolute_mag=sigma, + amplitude=1.0e-3, + ) + ax.plot( + absolute_mag, + lf.phi(absolute_mag), + lw=3, + color=color, + label=rf"$\sigma_M={sigma}$", + ) + + ax.set_yscale("log") + ax.invert_xaxis() + ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) + ax.set_ylabel( + r"$\Phi(M)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", + fontsize=LABEL_SIZE, + ) + ax.set_title("Gaussian luminosity functions", fontsize=TITLE_SIZE) + ax.tick_params(axis="both", labelsize=TICK_SIZE) + ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") + plt.tight_layout() + + +Lognormal luminosity function +----------------------------- + +The lognormal model is Gaussian in logarithmic luminosity rather than directly +Gaussian in magnitude. It is useful when the width of the population is more +naturally described in :math:`\log L`. + +.. plot:: + :include-source: True + :width: 520 + + import numpy as np + import matplotlib.pyplot as plt + import cmasher as cmr + + from lfkit import LuminosityFunction + + LABEL_SIZE = 15 + TICK_SIZE = 13 + TITLE_SIZE = 17 + LEGEND_SIZE = 15 + + gaussian = LuminosityFunction.gaussian( + mean_absolute_mag=-20.5, + sigma_absolute_mag=0.8, + amplitude=1.0e-3, + ) + + lognormal = LuminosityFunction.lognormal( + mean_absolute_mag=-20.5, + sigma_log_luminosity=0.35, + amplitude=1.0e-3, + ) + + absolute_mag = np.linspace(-24.0, -14.0, 500) + colors = cmr.take_cmap_colors("cmr.guppy", 2, cmap_range=(0.0, 0.2)) + + fig, ax = plt.subplots(figsize=(7.0, 5.0)) + ax.plot( + absolute_mag, + gaussian.phi(absolute_mag), + lw=3, + color=colors[0], + label="Gaussian in magnitude", + ) + ax.plot( + absolute_mag, + lognormal.phi(absolute_mag), + lw=3, + color=colors[1], + label="Lognormal in luminosity", + ) + + ax.set_yscale("log") + ax.invert_xaxis() + ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) + ax.set_ylabel( + r"$\Phi(M)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", + fontsize=LABEL_SIZE, + ) + ax.set_title("Gaussian-like luminosity functions", fontsize=TITLE_SIZE) + ax.tick_params(axis="both", labelsize=TICK_SIZE) + ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") + plt.tight_layout() \ No newline at end of file diff --git a/docs/examples/lf_models/index.rst b/docs/examples/lf_models/index.rst new file mode 100644 index 00000000..2959b81f --- /dev/null +++ b/docs/examples/lf_models/index.rst @@ -0,0 +1,33 @@ +.. |lfkitlogo| image:: /_static/logos/lfkit_logo-icon.png + :alt: LFKit logo + :width: 50px + +|lfkitlogo| Luminosity function models +====================================== + +This section introduces the luminosity function models exposed by +:class:`lfkit.LuminosityFunction`. These models describe the abundance of +galaxies as a function of magnitude, usually written as :math:`\Phi(M)`. + +The examples focus on constructing, evaluating, visualizing, and comparing +luminosity function models. Magnitude integrals, completeness calculations, +apparent magnitude limits, redshift-density weighting, and conditional +luminosity functions are covered on separate pages. + +The API is centered on :class:`lfkit.LuminosityFunction`. A luminosity function +object stores the chosen model and evaluates it through +:meth:`lfkit.LuminosityFunction.phi`. + +The number-density units follow the normalization supplied to the luminosity +function. For example, if ``phi_star`` is supplied in +:math:`{\rm Mpc}^{-3}\,{\rm mag}^{-1}`, then :math:`\Phi(M)` has units of +:math:`{\rm Mpc}^{-3}\,{\rm mag}^{-1}`. + +.. toctree:: + :maxdepth: 1 + + model_registry + schechter_models + gaussian_models + power_law_models + composite_models \ No newline at end of file diff --git a/docs/examples/model_registry.rst b/docs/examples/lf_models/model_registry.rst similarity index 100% rename from docs/examples/model_registry.rst rename to docs/examples/lf_models/model_registry.rst diff --git a/docs/examples/lf_models/power_law_models.rst b/docs/examples/lf_models/power_law_models.rst new file mode 100644 index 00000000..eb14b8cf --- /dev/null +++ b/docs/examples/lf_models/power_law_models.rst @@ -0,0 +1,205 @@ +.. |lfkitlogo| image:: /_static/logos/lfkit_logo-icon.png + :alt: LFKit logo + :width: 50px + +|lfkitlogo| Power-law models +============================ + +Power-law models describe luminosity functions whose abundance changes as a +power of luminosity ratio :math:`L/L_*`. They are useful for simple scaling +tests, bright-end or faint-end approximations, and populations that do not need +a Schechter-like exponential cutoff. + +Single power law +---------------- + +The single power-law model has one slope. In magnitude space, this gives a +straight-line trend when shown on a logarithmic :math:`\Phi(M)` axis. + +.. plot:: + :include-source: True + :width: 520 + + import numpy as np + import matplotlib.pyplot as plt + import cmasher as cmr + + from lfkit import LuminosityFunction + + LABEL_SIZE = 15 + TICK_SIZE = 13 + TITLE_SIZE = 17 + LEGEND_SIZE = 15 + + absolute_mag = np.linspace(-24.0, -14.0, 500) + alphas = [-0.5, -1.0, -1.5] + colors = cmr.take_cmap_colors("cmr.guppy", len(alphas), cmap_range=(0.0, 0.2)) + + fig, ax = plt.subplots(figsize=(7.0, 5.0)) + + for alpha, color in zip(alphas, colors): + lf = LuminosityFunction.power_law( + phi_star=1.0e-3, + m_star=-20.5, + alpha=alpha, + ) + ax.plot( + absolute_mag, + lf.phi(absolute_mag), + lw=3, + color=color, + label=rf"$\alpha={alpha}$", + ) + + ax.set_yscale("log") + ax.invert_xaxis() + ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) + ax.set_ylabel( + r"$\Phi(M)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", + fontsize=LABEL_SIZE, + ) + ax.set_title("Single power-law luminosity functions", fontsize=TITLE_SIZE) + ax.tick_params(axis="both", labelsize=TICK_SIZE) + ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") + plt.tight_layout() + + +Double and broken power laws +---------------------------- + +The double power law smoothly combines two slopes, while the broken power law +uses a sharp transition at :math:`M_*`. These forms are useful when the bright +and faint sides need different behaviour without using a Schechter cutoff. + +.. plot:: + :include-source: True + :width: 520 + + import numpy as np + import matplotlib.pyplot as plt + import cmasher as cmr + + from lfkit import LuminosityFunction + + LABEL_SIZE = 15 + TICK_SIZE = 13 + TITLE_SIZE = 17 + LEGEND_SIZE = 15 + + single = LuminosityFunction.power_law( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.4, + ) + + double = LuminosityFunction.double_power_law( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.4, + beta=-1.8, + ) + + broken = LuminosityFunction.broken_power_law( + phi_star=1.0e-3, + m_star=-20.5, + alpha_faint=-1.4, + alpha_bright=-1.8, + ) + + absolute_mag = np.linspace(-24.0, -14.0, 500) + colors = cmr.take_cmap_colors("cmr.guppy", 3, cmap_range=(0.0, 0.2)) + + fig, ax = plt.subplots(figsize=(7.0, 5.0)) + ax.plot( + absolute_mag, + single.phi(absolute_mag), + lw=3, + color=colors[0], + label="Single power law", + ) + ax.plot( + absolute_mag, + double.phi(absolute_mag), + lw=3, + color=colors[1], + label="Double power law", + ) + ax.plot( + absolute_mag, + broken.phi(absolute_mag), + lw=3, + color=colors[2], + label="Broken power law", + ) + + ax.set_yscale("log") + ax.invert_xaxis() + ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) + ax.set_ylabel( + r"$\Phi(M)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", + fontsize=LABEL_SIZE, + ) + ax.set_title("Power-law luminosity functions", fontsize=TITLE_SIZE) + ax.tick_params(axis="both", labelsize=TICK_SIZE) + ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") + plt.tight_layout() + + +Log-normalized power law +------------------------ + +The log-normalized power-law model is the same power-law shape, but the +normalization is supplied as ``log_phi_star``. This is convenient for fitting +or sampling applications where the normalization is naturally varied in +logarithmic space. + +.. plot:: + :include-source: True + :width: 520 + + import numpy as np + import matplotlib.pyplot as plt + import cmasher as cmr + + from lfkit import LuminosityFunction + + LABEL_SIZE = 15 + TICK_SIZE = 13 + TITLE_SIZE = 17 + LEGEND_SIZE = 15 + + absolute_mag = np.linspace(-24.0, -14.0, 500) + log_phi_stars = [-4.0, -3.5, -3.0] + colors = cmr.take_cmap_colors( + "cmr.guppy", + len(log_phi_stars), + cmap_range=(0.0, 0.2), + ) + + fig, ax = plt.subplots(figsize=(7.0, 5.0)) + + for log_phi_star, color in zip(log_phi_stars, colors): + lf = LuminosityFunction.log_power_law( + log_phi_star=log_phi_star, + m_star=-20.5, + alpha=-1.1, + ) + ax.plot( + absolute_mag, + lf.phi(absolute_mag), + lw=3, + color=color, + label=rf"$\log_{{10}}\phi_*={log_phi_star}$", + ) + + ax.set_yscale("log") + ax.invert_xaxis() + ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) + ax.set_ylabel( + r"$\Phi(M)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", + fontsize=LABEL_SIZE, + ) + ax.set_title("Log-normalized power-law models", fontsize=TITLE_SIZE) + ax.tick_params(axis="both", labelsize=TICK_SIZE) + ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") + plt.tight_layout() diff --git a/docs/examples/lf_models/schechter_models.rst b/docs/examples/lf_models/schechter_models.rst new file mode 100644 index 00000000..bedf572d --- /dev/null +++ b/docs/examples/lf_models/schechter_models.rst @@ -0,0 +1,264 @@ +.. |lfkitlogo| image:: /_static/logos/lfkit_logo-icon.png + :alt: LFKit logo + :width: 50px + +|lfkitlogo| Schechter family models +=================================== + +The Schechter family contains luminosity functions with a power-law faint end +and a bright-end cutoff. These models are commonly used when the abundance of +faint galaxies rises approximately as a power law, while the most luminous +galaxies are exponentially rare. + +Standard Schechter luminosity function +-------------------------------------- + +A Schechter luminosity function can be created with +:meth:`lfkit.LuminosityFunction.schechter`. The returned object evaluates +:math:`\Phi(M)` through :meth:`lfkit.LuminosityFunction.phi`. + +The Schechter luminosity function is evaluated in absolute magnitude. A +secondary *x*-axis can show the corresponding apparent magnitude at a fixed +luminosity distance using the LFKit magnitude converters. + +.. plot:: + :include-source: True + :width: 560 + + import numpy as np + import matplotlib.pyplot as plt + import cmasher as cmr + + from lfkit import LuminosityFunction + + LABEL_SIZE = 15 + TICK_SIZE = 13 + TITLE_SIZE = 17 + + lf = LuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + ) + + luminosity_distance_mpc = 3500.0 + absolute_mag = np.linspace(-24.0, -14.0, 500) + phi = lf.phi(absolute_mag) + + fig, ax = plt.subplots(figsize=(7.2, 5.0)) + ax.plot( + absolute_mag, + phi, + lw=3, + color=cmr.take_cmap_colors("cmr.guppy", 3, cmap_range=(0.0, 0.2))[1], + ) + + secax = ax.secondary_xaxis( + "top", + functions=( + lambda absolute_mag: lf.magnitudes.apparent_from_luminosity_distance( + absolute_mag, + luminosity_distance_mpc, + ), + lambda apparent_mag: lf.magnitudes.absolute_from_luminosity_distance( + apparent_mag, + luminosity_distance_mpc, + ), + ), + ) + + ax.set_yscale("log") + ax.invert_xaxis() + ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) + ax.set_ylabel( + r"$\Phi(M)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", + fontsize=LABEL_SIZE, + ) + ax.set_title("Schechter luminosity function", fontsize=TITLE_SIZE, pad=0.5) + ax.tick_params(axis="both", labelsize=TICK_SIZE) + + secax.set_xlabel("Apparent magnitude $m$", fontsize=LABEL_SIZE) + secax.tick_params(axis="x", labelsize=TICK_SIZE) + + plt.tight_layout() + + +Comparing Schechter slopes +-------------------------- + +Changing :math:`\alpha` modifies the faint-end behaviour of the luminosity +function. More negative values of :math:`\alpha` produce a steeper rise toward +faint magnitudes. + +.. plot:: + :include-source: True + :width: 520 + + import numpy as np + import matplotlib.pyplot as plt + import cmasher as cmr + + from lfkit import LuminosityFunction + + LABEL_SIZE = 15 + TICK_SIZE = 13 + TITLE_SIZE = 17 + LEGEND_SIZE = 15 + + absolute_mag = np.linspace(-24.0, -14.0, 500) + alphas = [-0.5, -0.75, -1.0, -1.25, -1.5] + colors = cmr.take_cmap_colors("cmr.guppy", len(alphas), cmap_range=(0.0, 0.2)) + + fig, ax = plt.subplots(figsize=(7.0, 5.0)) + + for alpha, color in zip(alphas, colors): + lf = LuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=alpha, + ) + ax.plot( + absolute_mag, + lf.phi(absolute_mag), + lw=3, + color=color, + label=rf"$\alpha={alpha}$", + ) + + ax.set_yscale("log") + ax.invert_xaxis() + ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) + ax.set_ylabel( + r"$\Phi(M)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", + fontsize=LABEL_SIZE, + ) + ax.set_title("Effect of the faint-end slope", fontsize=TITLE_SIZE) + ax.tick_params(axis="both", labelsize=TICK_SIZE) + ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") + plt.tight_layout() + + +Double Schechter luminosity function +------------------------------------ + +The double-Schechter model adds a second Schechter-like component. This is +useful when one faint-end slope is not flexible enough to describe the galaxy +population. + +.. plot:: + :include-source: True + :width: 520 + + import numpy as np + import matplotlib.pyplot as plt + import cmasher as cmr + + from lfkit import LuminosityFunction + + LABEL_SIZE = 15 + TICK_SIZE = 13 + TITLE_SIZE = 17 + LEGEND_SIZE = 15 + + single = LuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + ) + + double = LuminosityFunction.double_schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + beta=-1.5, + m_transition=-19.5, + ) + + absolute_mag = np.linspace(-24.0, -14.0, 500) + colors = cmr.take_cmap_colors("cmr.guppy", 2, cmap_range=(0.0, 0.2)) + + fig, ax = plt.subplots(figsize=(7.0, 5.0)) + ax.plot( + absolute_mag, + single.phi(absolute_mag), + lw=3, + color=colors[0], + label="Schechter", + ) + ax.plot( + absolute_mag, + double.phi(absolute_mag), + lw=3, + color=colors[1], + label="Double Schechter", + ) + + ax.set_yscale("log") + ax.invert_xaxis() + ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) + ax.set_ylabel( + r"$\Phi(M)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", + fontsize=LABEL_SIZE, + ) + ax.set_title("Schechter and double-Schechter models", fontsize=TITLE_SIZE) + ax.tick_params(axis="both", labelsize=TICK_SIZE) + ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") + plt.tight_layout() + + +Evolving Schechter luminosity function +-------------------------------------- + +An evolving Schechter luminosity function lets the Schechter parameters depend +on redshift through LFKit's registered parameter models. + +.. plot:: + :include-source: True + :width: 520 + + import numpy as np + import matplotlib.pyplot as plt + import cmasher as cmr + + from lfkit import LuminosityFunction + + LABEL_SIZE = 15 + TICK_SIZE = 13 + TITLE_SIZE = 17 + LEGEND_SIZE = 15 + + lf = LuminosityFunction.evolving_schechter( + phi_model="linear_p", + phi_kwargs={"phi_0_star": 1.0e-3, "p": 0.7}, + m_star_model="linear_q", + m_star_kwargs={"m_0_star": -20.5, "q": 0.8, "z_ref": 0.1}, + alpha_model="constant", + alpha_kwargs={"alpha": -1.1}, + ) + + absolute_mag = np.linspace(-24.0, -14.0, 500) + redshifts = [0.1, 0.6, 1.1] + colors = cmr.take_cmap_colors("cmr.guppy", len(redshifts), cmap_range=(0.0, 0.2)) + + fig, ax = plt.subplots(figsize=(7.0, 5.0)) + + for z_value, color in zip(redshifts, colors): + ax.plot( + absolute_mag, + lf.phi(absolute_mag, z_value), + lw=3, + color=color, + label=rf"$z={z_value}$", + ) + + ax.set_yscale("log") + ax.invert_xaxis() + ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) + ax.set_ylabel( + r"$\Phi(M, z)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", + fontsize=LABEL_SIZE, + ) + ax.set_title("Evolving Schechter luminosity function", fontsize=TITLE_SIZE) + ax.tick_params(axis="both", labelsize=TICK_SIZE) + ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") + plt.tight_layout() \ No newline at end of file diff --git a/docs/examples/luminosity_function_models.rst b/docs/examples/luminosity_function_models.rst deleted file mode 100644 index 32c3b2c8..00000000 --- a/docs/examples/luminosity_function_models.rst +++ /dev/null @@ -1,672 +0,0 @@ -.. |lfkitlogo| image:: /_static/logos/lfkit_logo-icon.png - :alt: LFKit logo - :width: 50px - -|lfkitlogo| Luminosity function models -====================================== - -This page introduces the luminosity function models exposed by -:class:`lfkit.LuminosityFunction`. These models describe the abundance of -galaxies as a function of magnitude, usually written as :math:`\Phi(M)`. - -The examples focus on constructing, evaluating, visualizing, and comparing -luminosity function models. Magnitude integrals, completeness calculations, -apparent magnitude limits, redshift-density weighting, and conditional -luminosity functions are covered on separate pages. - -The API is centered on :class:`lfkit.LuminosityFunction`. A luminosity function -object stores the chosen model and evaluates it through -:meth:`lfkit.LuminosityFunction.phi`. - -The number-density units follow the normalization supplied to the luminosity -function. For example, if ``phi_star`` is supplied in -:math:`{\rm Mpc}^{-3}\,{\rm mag}^{-1}`, then :math:`\Phi(M)` has units of -:math:`{\rm Mpc}^{-3}\,{\rm mag}^{-1}`. - - -Schechter family models ------------------------ - -The Schechter family is the main luminosity function model family currently -exposed by LFKit. It includes the standard Schechter model, double-Schechter -variants, and redshift-evolving Schechter models. - -These models are useful for describing galaxy luminosity functions with a -power-law faint end and an exponential bright-end cutoff. The faint end controls -the abundance of low-luminosity galaxies, while the bright-end cutoff suppresses -very luminous galaxies. The examples below show how to construct, evaluate, -compare, and inspect Schechter-family models. - - -Standard Schechter luminosity function -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -A Schechter luminosity function can be created with -:meth:`lfkit.LuminosityFunction.schechter`. The returned object evaluates -:math:`\Phi(M)` through :meth:`lfkit.LuminosityFunction.phi`. - -This example shows the basic shape of the model in absolute magnitude. The -curve rises toward fainter magnitudes because of the power-law faint end, while -the abundance drops rapidly at the bright end because of the exponential cutoff. - -.. plot:: - :include-source: True - :width: 520 - - import numpy as np - import matplotlib.pyplot as plt - import cmasher as cmr - - from lfkit import LuminosityFunction - - LABEL_SIZE = 15 - TICK_SIZE = 13 - TITLE_SIZE = 17 - - lf = LuminosityFunction.schechter( - phi_star=1.0e-3, - m_star=-20.5, - alpha=-1.1, - ) - - absolute_mag = np.linspace(-24.0, -14.0, 500) - phi = lf.phi(absolute_mag) - - fig, ax = plt.subplots(figsize=(7.0, 5.0)) - ax.plot( - absolute_mag, - phi, - lw=3, - color=cmr.take_cmap_colors("cmr.guppy", 3, cmap_range=(0., 0.2))[1], - ) - - ax.set_yscale("log") - ax.invert_xaxis() - ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) - ax.set_ylabel( - r"$\Phi(M)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", - fontsize=LABEL_SIZE, - ) - ax.set_title("Schechter luminosity function", fontsize=TITLE_SIZE) - ax.tick_params(axis="both", labelsize=TICK_SIZE) - plt.tight_layout() - - -Standard Schechter luminosity function with apparent magnitude axis -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The Schechter luminosity function is evaluated in absolute magnitude. A -secondary *x*-axis can show the corresponding apparent magnitude at a fixed -luminosity distance using the LFKit magnitude converters. - -This keeps the model-native absolute magnitude axis while also showing where -the same magnitude range would appear observationally. The apparent magnitude -axis is only a reference conversion for the chosen luminosity distance; changing -that distance would shift the upper axis. - -.. plot:: - :include-source: True - :width: 560 - - import numpy as np - import matplotlib.pyplot as plt - import cmasher as cmr - - from lfkit import LuminosityFunction - - LABEL_SIZE = 15 - TICK_SIZE = 13 - TITLE_SIZE = 17 - - lf = LuminosityFunction.schechter( - phi_star=1.0e-3, - m_star=-20.5, - alpha=-1.1, - ) - - luminosity_distance_mpc = 3500.0 - - absolute_mag = np.linspace(-24.0, -14.0, 500) - phi = lf.phi(absolute_mag) - - fig, ax = plt.subplots(figsize=(7.2, 5.0)) - ax.plot( - absolute_mag, - phi, - lw=3, - color=cmr.take_cmap_colors("cmr.guppy", 3, cmap_range=(0.0, 0.2))[1], - ) - - secax = ax.secondary_xaxis( - "top", - functions=( - lambda absolute_mag: lf.magnitudes.apparent_from_luminosity_distance( - absolute_mag, - luminosity_distance_mpc, - ), - lambda apparent_mag: lf.magnitudes.absolute_from_luminosity_distance( - apparent_mag, - luminosity_distance_mpc, - ), - ), - ) - - ax.set_yscale("log") - ax.invert_xaxis() - ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) - ax.set_ylabel( - r"$\Phi(M)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", - fontsize=LABEL_SIZE, - ) - ax.set_title( - "Schechter luminosity function", - fontsize=TITLE_SIZE, - pad=0.5, - ) - ax.tick_params(axis="both", labelsize=TICK_SIZE) - - secax.set_xlabel("Apparent magnitude $m$", fontsize=LABEL_SIZE) - secax.tick_params(axis="x", labelsize=TICK_SIZE) - - plt.tight_layout() - - - -Comparing Schechter slopes -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Changing :math:`\alpha` modifies the faint-end behaviour of the luminosity -function. More negative values of :math:`\alpha` produce a steeper rise toward -faint magnitudes, while less negative values give a shallower faint-end -population. - -This comparison keeps the other Schechter parameters fixed so that the effect -of :math:`\alpha` is isolated. This is useful because the faint-end slope often -controls how strongly low-luminosity galaxies contribute to integrated -quantities such as number density or luminosity density. - -.. plot:: - :include-source: True - :width: 520 - - import numpy as np - import matplotlib.pyplot as plt - import cmasher as cmr - - from lfkit import LuminosityFunction - - LABEL_SIZE = 15 - TICK_SIZE = 13 - TITLE_SIZE = 17 - LEGEND_SIZE = 15 - - absolute_mag = np.linspace(-24.0, -14.0, 500) - alphas = [-0.5, -0.75, -1.0, -1.25, -1.5] - - colors = cmr.take_cmap_colors( - "cmr.guppy", - len(alphas), - cmap_range=(0.0, 0.2) - ) - - fig, ax = plt.subplots(figsize=(7.0, 5.0)) - - for alpha, color in zip(alphas, colors): - lf = LuminosityFunction.schechter( - phi_star=1.0e-3, - m_star=-20.5, - alpha=alpha, - ) - ax.plot( - absolute_mag, - lf.phi(absolute_mag), - lw=3, - color=color, - label=rf"$\alpha={alpha}$", - ) - - ax.set_yscale("log") - ax.invert_xaxis() - ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) - ax.set_ylabel( - r"$\Phi(M)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", - fontsize=LABEL_SIZE, - ) - ax.set_title("Effect of the faint-end slope", fontsize=TITLE_SIZE) - ax.tick_params(axis="both", labelsize=TICK_SIZE) - ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") - plt.tight_layout() - - -Double Schechter luminosity function -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The API also exposes a double-Schechter constructor. This is useful for models -that need extra flexibility at the faint end while retaining a Schechter-like -bright-end cutoff. - -The double-Schechter form adds a second faint-end component. This can represent -a luminosity function whose faint population cannot be captured well by one -single Schechter slope. - -.. plot:: - :include-source: True - :width: 520 - - import numpy as np - import matplotlib.pyplot as plt - import cmasher as cmr - - from lfkit import LuminosityFunction - - LABEL_SIZE = 15 - TICK_SIZE = 13 - TITLE_SIZE = 17 - LEGEND_SIZE = 15 - - single = LuminosityFunction.schechter( - phi_star=1.0e-3, - m_star=-20.5, - alpha=-1.1, - ) - - double = LuminosityFunction.double_schechter( - phi_star=1.0e-3, - m_star=-20.5, - alpha=-1.1, - beta=-1.5, - m_transition=-19.5, - ) - - absolute_mag = np.linspace(-24.0, -14.0, 500) - colors = cmr.take_cmap_colors("cmr.guppy", 2, cmap_range=(0.0, 0.2)) - - fig, ax = plt.subplots(figsize=(7.0, 5.0)) - ax.plot( - absolute_mag, - single.phi(absolute_mag), - lw=3, - color=colors[0], - label="Schechter", - ) - ax.plot( - absolute_mag, - double.phi(absolute_mag), - lw=3, - color=colors[1], - label="Double Schechter", - ) - - ax.set_yscale("log") - ax.invert_xaxis() - ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) - ax.set_ylabel( - r"$\Phi(M)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", - fontsize=LABEL_SIZE, - ) - ax.set_title("Schechter and double-Schechter models", fontsize=TITLE_SIZE) - ax.tick_params(axis="both", labelsize=TICK_SIZE) - ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") - plt.tight_layout() - - -Evolving Schechter luminosity function -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -An evolving Schechter luminosity function lets the Schechter parameters depend -on redshift through LFKit's registered parameter models. This is useful when the -same LF object should evaluate :math:`\Phi(M, z)` at many redshifts. - -The curves below show how the predicted luminosity function changes when the -normalization, characteristic magnitude, or slope evolve with redshift. This is -the model form used when the galaxy population is not assumed to be fixed across -cosmic time. - -.. plot:: - :include-source: True - :width: 520 - - import numpy as np - import matplotlib.pyplot as plt - import cmasher as cmr - - from lfkit import LuminosityFunction - - LABEL_SIZE = 15 - TICK_SIZE = 13 - TITLE_SIZE = 17 - LEGEND_SIZE = 15 - - lf = LuminosityFunction.evolving_schechter( - phi_model="linear_p", - phi_kwargs={"phi_0_star": 1.0e-3, "p": 0.7}, - m_star_model="linear_q", - m_star_kwargs={"m_0_star": -20.5, "q": 0.8, "z_ref": 0.1}, - alpha_model="constant", - alpha_kwargs={"alpha": -1.1}, - ) - - absolute_mag = np.linspace(-24.0, -14.0, 500) - redshifts = [0.1, 0.6, 1.1] - colors = cmr.take_cmap_colors( - "cmr.guppy", - len(redshifts), - cmap_range=(0.0, 0.2) - ) - - fig, ax = plt.subplots(figsize=(7.0, 5.0)) - - for z_value, color in zip(redshifts, colors): - phi = lf.phi(absolute_mag, z_value) - ax.plot( - absolute_mag, - phi, - lw=3, - color=color, - label=rf"$z={z_value}$", - ) - - ax.set_yscale("log") - ax.invert_xaxis() - ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) - ax.set_ylabel( - r"$\Phi(M, z)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", - fontsize=LABEL_SIZE, - ) - ax.set_title("Evolving Schechter luminosity function", fontsize=TITLE_SIZE) - ax.tick_params(axis="both", labelsize=TICK_SIZE) - ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") - plt.tight_layout() - - -Evolving Schechter luminosity function with apparent magnitude axis -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The evolving Schechter model is evaluated as :math:`\Phi(M, z)`. A secondary -*x*-axis can show the apparent magnitude corresponding to the absolute magnitude -range at a chosen reference luminosity distance. - -Here, the curves are evaluated at several redshifts, while the upper apparent -magnitude axis is defined for the reference redshift :math:`z=0.6`. This keeps -the bottom axis model-native and avoids mixing several different -distance-redshift mappings into one top axis. The top axis should therefore be -read as a reference guide, not as a separate conversion for every curve. - -.. plot:: - :include-source: True - :width: 560 - - import numpy as np - import matplotlib.pyplot as plt - import cmasher as cmr - - from lfkit import LuminosityFunction - - LABEL_SIZE = 15 - TICK_SIZE = 13 - TITLE_SIZE = 17 - LEGEND_SIZE = 15 - - lf = LuminosityFunction.evolving_schechter( - phi_model="linear_p", - phi_kwargs={"phi_0_star": 1.0e-3, "p": 0.7}, - m_star_model="linear_q", - m_star_kwargs={"m_0_star": -20.5, "q": 0.8, "z_ref": 0.1}, - alpha_model="constant", - alpha_kwargs={"alpha": -1.1}, - ) - - absolute_mag = np.linspace(-24.0, -14.0, 500) - redshifts = [0.1, 0.6, 1.1] - - reference_redshift = 0.6 - luminosity_distance_mpc = { - 0.1: 460.0, - 0.6: 3500.0, - 1.1: 7600.0, - } - reference_luminosity_distance_mpc = luminosity_distance_mpc[reference_redshift] - - colors = cmr.take_cmap_colors( - "cmr.guppy", - len(redshifts), - cmap_range=(0.0, 0.2) - ) - - fig, ax = plt.subplots(figsize=(7.2, 5.0)) - - for z_value, color in zip(redshifts, colors): - phi = lf.phi(absolute_mag, z_value) - ax.plot( - absolute_mag, - phi, - lw=3, - color=color, - label=rf"$z={z_value}$", - ) - - secax = ax.secondary_xaxis( - "top", - functions=( - lambda absolute_mag: lf.magnitudes.apparent_from_luminosity_distance( - absolute_mag, - reference_luminosity_distance_mpc, - ), - lambda apparent_mag: lf.magnitudes.absolute_from_luminosity_distance( - apparent_mag, - reference_luminosity_distance_mpc, - ), - ), - ) - - ax.set_yscale("log") - ax.invert_xaxis() - ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) - ax.set_ylabel( - r"$\Phi(M, z)$ [$\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1}$]", - fontsize=LABEL_SIZE, - ) - ax.set_title( - "Evolving Schechter luminosity function", - fontsize=TITLE_SIZE, - pad=0.5, - ) - ax.tick_params(axis="both", labelsize=TICK_SIZE) - ax.legend(frameon=True, fontsize=LEGEND_SIZE, loc="best") - - secax.set_xlabel( - rf"Apparent magnitude $m$ at $z={reference_redshift}$", - fontsize=LABEL_SIZE, - ) - secax.tick_params(axis="x", labelsize=TICK_SIZE) - - plt.tight_layout() - - -Inspecting evolving parameters -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -For evolving models, :meth:`lfkit.LuminosityFunction.parameters` evaluates the -registered parameter models at the requested redshift. This is useful for -checking the model behaviour before using the LF in number-density or selection -calculations. - -Here all three Schechter parameters evolve with redshift, including the -faint-end slope :math:`\alpha(z)`. This diagnostic separates the ingredients of -the luminosity function: :math:`\phi_*` controls the normalization, -:math:`M_*` sets the characteristic magnitude, and :math:`\alpha` controls the -faint-end slope. - -.. plot:: - :include-source: True - :width: 560 - - import numpy as np - import matplotlib.pyplot as plt - import cmasher as cmr - - from lfkit import LuminosityFunction - - LABEL_SIZE = 15 - TICK_SIZE = 13 - TITLE_SIZE = 17 - - lf = LuminosityFunction.evolving_schechter( - phi_model="linear_p", - phi_kwargs={"phi_0_star": 1.0e-3, "p": 0.7}, - m_star_model="linear_q", - m_star_kwargs={"m_0_star": -20.5, "q": 0.8, "z_ref": 0.1}, - alpha_model="linear", - alpha_kwargs={"alpha_0": -1.0, "alpha_1": -0.25, "z_ref": 0.1}, - ) - - redshift = np.linspace(0.0, 1.5, 200) - phi_star, m_star, alpha = lf.parameters(redshift) - colors = cmr.take_cmap_colors("cmr.guppy", 3, cmap_range=(0.0, 0.2)) - - fig, axes = plt.subplots( - nrows=3, - ncols=1, - figsize=(7.0, 8.0), - sharex=True, - ) - - axes[0].plot( - redshift, - phi_star / 1.0e-3, - lw=3, - color=colors[0], - ) - axes[0].set_ylabel(r"$\phi_*/10^{-3}$", fontsize=LABEL_SIZE) - - axes[1].plot( - redshift, - m_star, - lw=3, - color=colors[1], - ) - axes[1].set_ylabel(r"$M_*$", fontsize=LABEL_SIZE) - - axes[2].plot( - redshift, - alpha, - lw=3, - color=colors[2], - ) - axes[2].set_ylabel(r"$\alpha(z)$", fontsize=LABEL_SIZE) - axes[2].set_xlabel("Redshift $z$", fontsize=LABEL_SIZE) - - axes[0].set_title("Evolving Schechter parameters", fontsize=TITLE_SIZE) - - for axis in axes: - axis.tick_params(axis="both", labelsize=TICK_SIZE) - - plt.tight_layout() - - -Evolving Schechter surface -~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The same evolving model can be shown over the full magnitude-redshift plane. -The filled colour scale shows :math:`\log_{10}\Phi(M, z)`, while contours mark -constant abundance levels. - -This view is useful for seeing the joint magnitude and redshift dependence in -one panel. Horizontal changes show how the luminosity function varies with -magnitude, while vertical changes show how the evolving parameters modify the -model with redshift. - -.. plot:: - :include-source: True - :width: 560 - - import numpy as np - import matplotlib.pyplot as plt - import cmasher as cmr - - from lfkit import LuminosityFunction - - LABEL_SIZE = 15 - TICK_SIZE = 13 - TITLE_SIZE = 17 - - lf = LuminosityFunction.evolving_schechter( - phi_model="linear_p", - phi_kwargs={"phi_0_star": 1.0e-3, "p": 0.7}, - m_star_model="linear_q", - m_star_kwargs={"m_0_star": -20.5, "q": 0.8, "z_ref": 0.1}, - alpha_model="constant", - alpha_kwargs={"alpha": -1.1}, - ) - - absolute_mag = np.linspace(-24.0, -16.0, 220) - redshift = np.linspace(0.0, 1.5, 180) - mag_grid, z_grid = np.meshgrid(absolute_mag, redshift) - - phi = lf.phi(mag_grid, z_grid) - log_phi = np.log10(phi) - - fig, ax = plt.subplots(figsize=(7.2, 5.0)) - - mesh = ax.pcolormesh( - absolute_mag, - redshift, - log_phi, - shading="auto", - cmap=cmr.get_sub_cmap('cmr.guppy_r', 0.0, 1) - ) - - contour_levels = [-5.0, -4.0, -3.0, -2.0] - contours = ax.contour( - absolute_mag, - redshift, - log_phi, - levels=contour_levels, - colors="white", - linewidths=1.2, - ) - ax.clabel(contours, inline=True, fontsize=TICK_SIZE, fmt=r"$10^{%.0f}$") - - ax.invert_xaxis() - ax.set_xlabel("Absolute magnitude $M$", fontsize=LABEL_SIZE) - ax.set_ylabel("Redshift $z$", fontsize=LABEL_SIZE) - ax.set_title("Evolving Schechter LF surface", fontsize=TITLE_SIZE) - ax.tick_params(axis="both", labelsize=TICK_SIZE) - - cbar = fig.colorbar(mesh, ax=ax) - cbar.set_label( - r"$\log_{10}\Phi(M, z)$ " - r"[$\log_{10}(\mathrm{Mpc}^{-3}\,\mathrm{mag}^{-1})$]", - fontsize=LABEL_SIZE, - ) - cbar.ax.tick_params(labelsize=TICK_SIZE) - - plt.tight_layout() - - -Other luminosity function parametrizations ------------------------------------------- - -Additional luminosity function parametrizations can be added here as they are -implemented in the public API. - -Examples may include Saunders or modified-Schechter models, double-power-law -forms, lognormal-inspired parametrizations, or other survey-specific luminosity -function models. This section is intentionally kept as a placeholder so the -page can grow beyond the Schechter family without mixing all models under one -flat heading structure. - - -Available models ----------------- - -The API can report the registered luminosity function models and parameter -models. This is useful for examples, validation, and interactive exploration. - -.. code-block:: python - - from lfkit import LuminosityFunction - - LuminosityFunction.available_models() - LuminosityFunction.available_from_m_models() - LuminosityFunction.available_parameter_models() diff --git a/docs/examples/magnitude_integrals.rst b/docs/examples/magnitude_integrals.rst index ca60a309..31cd1e74 100644 --- a/docs/examples/magnitude_integrals.rst +++ b/docs/examples/magnitude_integrals.rst @@ -2,8 +2,8 @@ :alt: LFKit logo :width: 50px -|lfkitlogo| Luminosity function magnitude integrals -=================================================== +|lfkitlogo| Magnitude integrals +=============================== This page shows how to integrate a bound :class:`lfkit.LuminosityFunction` over absolute magnitude. diff --git a/docs/examples/magnitudes_and_luminosities.rst b/docs/examples/magnitudes_and_luminosities.rst index f4cabdfc..67d374e9 100644 --- a/docs/examples/magnitudes_and_luminosities.rst +++ b/docs/examples/magnitudes_and_luminosities.rst @@ -5,8 +5,8 @@ |lfkitlogo| Magnitudes and luminosities ======================================= -This page shows magnitude and luminosity helper examples for -:class:`lfkit.LuminosityFunction`. +This page shows the public magnitude and luminosity helper namespaces attached +to :class:`lfkit.LuminosityFunction`. The examples use the ``lf.magnitudes`` and ``lf.luminosities`` namespaces, plus :meth:`lfkit.LuminosityFunction.phi_from_m` for evaluating a luminosity function diff --git a/src/lfkit/api/_clf_models.py b/src/lfkit/api/_clf_models.py deleted file mode 100644 index 8bd77c21..00000000 --- a/src/lfkit/api/_clf_models.py +++ /dev/null @@ -1,23 +0,0 @@ -"""User-facing conditional luminosity function model API namespace.""" - -from __future__ import annotations - -from lfkit.photometry.conditional_lf_models import ( - conditional_schechter, - conditional_double_schechter, - conditional_evolving_schechter, - lognormal_conditional_lf, - modified_schechter_conditional_lf, - two_component_conditional_lf, -) - - -class LFConditionalModelsAPI: - """Grouped API for evaluating conditional luminosity function models.""" - - schechter = staticmethod(conditional_schechter) - evolving_schechter = staticmethod(conditional_evolving_schechter) - double_schechter = staticmethod(conditional_double_schechter) - lognormal = staticmethod(lognormal_conditional_lf) - modified_schechter = staticmethod(modified_schechter_conditional_lf) - two_component = staticmethod(two_component_conditional_lf) diff --git a/src/lfkit/api/_completeness.py b/src/lfkit/api/_completeness.py deleted file mode 100644 index 40f6cc94..00000000 --- a/src/lfkit/api/_completeness.py +++ /dev/null @@ -1,51 +0,0 @@ -"""User-facing catalog-completeness API namespace.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from lfkit.api._expose import expose_lf_function -from lfkit.photometry.catalog_completeness import ( - absolute_magnitude_limit, - catalog_completeness_fraction, - missing_number_density, - observed_number_density, - out_of_catalog_fraction, -) - -if TYPE_CHECKING: - from lfkit.api.luminosity_function import LuminosityFunction - - -class LFCompletenessAPI: - """Grouped API for catalog-completeness calculations. - - Args: - lf: Parent luminosity function object. - """ - - def __init__(self, lf: LuminosityFunction) -> None: - self.lf = lf - - -_COMPLETENESS_METHODS = { - "observed_number_density": observed_number_density, - "missing_number_density": missing_number_density, - "catalog_fraction": catalog_completeness_fraction, - "out_of_catalog_fraction": out_of_catalog_fraction, -} - - -for method_name, function in _COMPLETENESS_METHODS.items(): - setattr( - LFCompletenessAPI, - method_name, - expose_lf_function( - function, - lf_arg_position=None, - lf_arg_name="lf", - ), - ) - - -LFCompletenessAPI.absolute_magnitude_limit = staticmethod(absolute_magnitude_limit) diff --git a/src/lfkit/api/_expose.py b/src/lfkit/api/_expose.py deleted file mode 100644 index 7b101045..00000000 --- a/src/lfkit/api/_expose.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Helpers for exposing low-level functions through API namespaces.""" - -from __future__ import annotations - -from collections.abc import Callable -from functools import wraps -from typing import Any - - -def expose_lf_function( - function: Callable[..., Any], - *, - lf_arg_position: int | None = 1, - lf_arg_name: str | None = None, -) -> Callable[..., Any]: - """Expose a low-level LF function as a bound API method. - - Args: - function: Low-level function to expose. - lf_arg_position: Positional location where the LF callable is inserted. - Use this when the low-level function expects ``lf_callable`` as a - positional argument. - lf_arg_name: Keyword name that receives the LF callable. Use this when - the low-level function expects a named LF callable argument. - - Returns: - Bound method that injects ``self.lf._as_callable()``. - """ - - @wraps(function) - def method(self, *args, **kwargs): - lf_callable = self.lf._as_callable() - - if lf_arg_name is not None: - kwargs[lf_arg_name] = lf_callable - return function(*args, **kwargs) - - if lf_arg_position is None: - return function(*args, **kwargs) - - args_list = list(args) - args_list.insert(lf_arg_position, lf_callable) - return function(*args_list, **kwargs) - - return method diff --git a/src/lfkit/api/_integrals.py b/src/lfkit/api/_integrals.py deleted file mode 100644 index 03f4e074..00000000 --- a/src/lfkit/api/_integrals.py +++ /dev/null @@ -1,53 +0,0 @@ -"""User-facing luminosity function integral API namespace.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from lfkit.api._expose import expose_lf_function -from lfkit.photometry.lf_integrals import ( - cumulative_number_density, - integrated_luminosity_density, - integrated_number_density, - lf_weighted_integral, - magnitude_window_number_density, - mean_luminosity, - selection_weighted_number_density, -) - -if TYPE_CHECKING: - from lfkit.api.luminosity_function import LuminosityFunction - - -class LFIntegralsAPI: - """Grouped API for luminosity function integrals. - - Args: - lf: Parent luminosity function object. - """ - - def __init__(self, lf: LuminosityFunction) -> None: - self.lf = lf - - -_INTEGRAL_METHODS = { - "number_density": integrated_number_density, - "weighted": lf_weighted_integral, - "selection_weighted_number_density": selection_weighted_number_density, - "luminosity_density": integrated_luminosity_density, - "mean_luminosity": mean_luminosity, - "cumulative_number_density": cumulative_number_density, - "magnitude_window_number_density": magnitude_window_number_density, -} - - -for method_name, function in _INTEGRAL_METHODS.items(): - setattr( - LFIntegralsAPI, - method_name, - expose_lf_function( - function, - lf_arg_position=None, - lf_arg_name="lf", - ), - ) diff --git a/src/lfkit/api/_lf_param_models.py b/src/lfkit/api/_lf_param_models.py deleted file mode 100644 index 29b19b67..00000000 --- a/src/lfkit/api/_lf_param_models.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Luminosity-function model registries used by the public API.""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Any, TypedDict - -from lfkit.photometry.conditional_lf_models import ( - conditional_schechter, - conditional_double_schechter, - conditional_evolving_schechter, - lognormal_conditional_lf, - modified_schechter_conditional_lf, - two_component_conditional_lf, -) -from lfkit.photometry.luminosity_function import ( - schechter, - double_schechter, - double_schechter_from_m, - evolving_schechter, - evolving_schechter_from_m, - schechter_from_m, -) - - -class LFModelSpec(TypedDict): - """Description of a luminosity function model exposed by the API.""" - - function: Callable[..., Any] - requires_z: bool - - -LF_MODELS: dict[str, LFModelSpec] = { - "schechter": { - "function": schechter, - "requires_z": False, - }, - "evolving_schechter": { - "function": evolving_schechter, - "requires_z": True, - }, - "double_schechter": { - "function": double_schechter, - "requires_z": False, - }, - "conditional_schechter": { - "function": conditional_schechter, - "requires_z": True, - }, - "conditional_evolving_schechter": { - "function": conditional_evolving_schechter, - "requires_z": True, - }, - "conditional_double_schechter": { - "function": conditional_double_schechter, - "requires_z": True, - }, - "lognormal_conditional_lf": { - "function": lognormal_conditional_lf, - "requires_z": True, - }, - "modified_schechter_conditional_lf": { - "function": modified_schechter_conditional_lf, - "requires_z": True, - }, - "two_component_conditional_lf": { - "function": two_component_conditional_lf, - "requires_z": True, - }, -} - - -LF_FROM_M_MODELS: dict[str, Callable[..., Any]] = { - "schechter": schechter_from_m, - "evolving_schechter": evolving_schechter_from_m, - "double_schechter": double_schechter_from_m, -} diff --git a/src/lfkit/api/_luminosities.py b/src/lfkit/api/_luminosities.py deleted file mode 100644 index efb7824f..00000000 --- a/src/lfkit/api/_luminosities.py +++ /dev/null @@ -1,36 +0,0 @@ -"""User-facing luminosity and magnitude conversion API namespace.""" - -from __future__ import annotations - -from lfkit.photometry.luminosities import ( - luminosity_from_magnitude, - luminosity_ratio, - luminosity_ratio_from_magnitudes, - luminosity_weight_from_magnitude, - magnitude_difference_from_luminosity_ratio, - sample_schechter_luminosity, - schechter_cumulative_number_density_luminosity, - schechter_luminosity_density, - schechter_mean_luminosity, - schechter_selection_function, -) - - -class LFLuminositiesAPI: - """Grouped API for luminosity, magnitude, and Schechter-luminosity helpers.""" - - ratio = staticmethod(luminosity_ratio) - ratio_from_magnitudes = staticmethod(luminosity_ratio_from_magnitudes) - magnitude_difference_from_ratio = staticmethod( - magnitude_difference_from_luminosity_ratio - ) - weight_from_magnitude = staticmethod(luminosity_weight_from_magnitude) - from_magnitude = staticmethod(luminosity_from_magnitude) - - schechter_cumulative_number_density = staticmethod( - schechter_cumulative_number_density_luminosity - ) - schechter_luminosity_density = staticmethod(schechter_luminosity_density) - schechter_mean_luminosity = staticmethod(schechter_mean_luminosity) - sample_schechter = staticmethod(sample_schechter_luminosity) - schechter_selection = staticmethod(schechter_selection_function) diff --git a/src/lfkit/api/_magnitudes.py b/src/lfkit/api/_magnitudes.py deleted file mode 100644 index a8d9632e..00000000 --- a/src/lfkit/api/_magnitudes.py +++ /dev/null @@ -1,27 +0,0 @@ -"""User-facing magnitude conversion API namespace.""" - -from __future__ import annotations - -from lfkit.photometry.magnitudes import ( - absolute_magnitude, - absolute_magnitude_from_luminosity_distance, - apparent_magnitude, - apparent_magnitude_from_luminosity_distance, - total_magnitude_correction, -) - - -class LFMagnitudesAPI: - """Grouped API for apparent- and absolute magnitude conversions.""" - - correction = staticmethod(total_magnitude_correction) - - absolute = staticmethod(absolute_magnitude) - absolute_from_luminosity_distance = staticmethod( - absolute_magnitude_from_luminosity_distance - ) - - apparent = staticmethod(apparent_magnitude) - apparent_from_luminosity_distance = staticmethod( - apparent_magnitude_from_luminosity_distance - ) diff --git a/src/lfkit/api/_namespaces.py b/src/lfkit/api/_namespaces.py new file mode 100644 index 00000000..cb6947e8 --- /dev/null +++ b/src/lfkit/api/_namespaces.py @@ -0,0 +1,139 @@ +"""User-facing luminosity-function API namespaces.""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import wraps +from typing import Any + +from lfkit.luminosity_functions import completeness as lf_completeness +from lfkit.luminosity_functions import integrals as lf_integrals +from lfkit.luminosity_functions import redshift_density as lf_redshift_density +from lfkit.photometry import luminosities as photo_luminosities +from lfkit.photometry import magnitudes as photo_magnitudes + + +class LFIntegralsAPI: + """Grouped API for luminosity function integrals.""" + + def __init__(self, lf) -> None: + self.lf = lf + + +class LFCompletenessAPI: + """Grouped API for catalog-completeness calculations.""" + + def __init__(self, lf) -> None: + self.lf = lf + + +class LFRedshiftDensityAPI: + """Grouped API for LF-weighted redshift-density calculations.""" + + def __init__(self, lf) -> None: + self.lf = lf + + +class LFMagnitudesAPI: + """Grouped API for apparent- and absolute-magnitude conversions.""" + + +class LFLuminositiesAPI: + """Grouped API for luminosity and magnitude conversion helpers.""" + + +_API_NAMESPACES = { + LFIntegralsAPI: { + "module": lf_integrals, + "bound_to_lf": True, + "lf_arg_name": "lf", + }, + LFCompletenessAPI: { + "module": lf_completeness, + "bound_to_lf": True, + "lf_arg_name": "lf", + "static_functions": {"absolute_magnitude_limit"}, + }, + LFRedshiftDensityAPI: { + "module": lf_redshift_density, + "bound_to_lf": True, + "lf_arg_position": 1, + }, + LFMagnitudesAPI: { + "module": photo_magnitudes, + "bound_to_lf": False, + }, + LFLuminositiesAPI: { + "module": photo_luminosities, + "bound_to_lf": False, + }, +} + + +def expose_lf_function( + function: Callable[..., Any], + *, + lf_arg_position: int | None = None, + lf_arg_name: str | None = None, +) -> Callable[..., Any]: + """Expose a low-level LF function as a bound API method.""" + + @wraps(function) + def method(self, *args, **kwargs): + lf_callable = self.lf._as_callable() + + if lf_arg_name is not None: + kwargs[lf_arg_name] = lf_callable + return function(*args, **kwargs) + + if lf_arg_position is None: + return function(*args, **kwargs) + + args_list = list(args) + args_list.insert(lf_arg_position, lf_callable) + return function(*args_list, **kwargs) + + return method + + +def _public_functions(module: object) -> dict[str, Callable[..., Any]]: + """Return callable public functions declared by a module.""" + return { + name: getattr(module, name) + for name in getattr(module, "__all__", []) + if callable(getattr(module, name)) + } + + +def _method_name(module: object, function_name: str) -> str: + """Return API method name for a low-level function.""" + aliases = getattr(module, "__api_aliases__", {}) + return aliases.get(function_name, function_name) + + +def _attach_api_methods() -> None: + """Attach low-level functions to their API namespace classes.""" + for api_cls, spec in _API_NAMESPACES.items(): + module = spec["module"] + bound_to_lf = spec.get("bound_to_lf", False) + static_functions = spec.get("static_functions", set()) + + for function_name, function in _public_functions(module).items(): + method_name = _method_name(module, function_name) + + if not bound_to_lf or function_name in static_functions: + setattr(api_cls, method_name, staticmethod(function)) + continue + + setattr( + api_cls, + method_name, + expose_lf_function( + function, + lf_arg_position=spec.get("lf_arg_position"), + lf_arg_name=spec.get("lf_arg_name"), + ), + ) + + +_attach_api_methods() diff --git a/src/lfkit/api/_redshift_density.py b/src/lfkit/api/_redshift_density.py deleted file mode 100644 index 6b6ce01b..00000000 --- a/src/lfkit/api/_redshift_density.py +++ /dev/null @@ -1,39 +0,0 @@ -"""User-facing LF redshift-density API namespace.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from lfkit.api._expose import expose_lf_function -from lfkit.photometry.lf_redshift_density import ( - lf_integrated_number_density, - lf_weighted_redshift_density, -) - -if TYPE_CHECKING: - from lfkit.api.luminosity_function import LuminosityFunction - - -class LFRedshiftDensityAPI: - """Grouped API for LF-weighted redshift-density calculations. - - Args: - lf: Parent luminosity function object. - """ - - def __init__(self, lf: LuminosityFunction) -> None: - self.lf = lf - - -_REDSHIFT_DENSITY_METHODS = { - "integrated_number_density": lf_integrated_number_density, - "weighted": lf_weighted_redshift_density, -} - - -for method_name, function in _REDSHIFT_DENSITY_METHODS.items(): - setattr( - LFRedshiftDensityAPI, - method_name, - expose_lf_function(function, lf_arg_position=1), - ) diff --git a/src/lfkit/api/conditional_luminosity_function.py b/src/lfkit/api/conditional_luminosity_function.py index 07c65d9a..f6478bc9 100644 --- a/src/lfkit/api/conditional_luminosity_function.py +++ b/src/lfkit/api/conditional_luminosity_function.py @@ -2,160 +2,184 @@ from __future__ import annotations +import inspect from collections.abc import Mapping +from typing import Any -from lfkit.api.luminosity_function import LuminosityFunction -from lfkit.utils.types import ConditionalParameter, ParameterValue +import numpy as np +from lfkit.api.luminosity_function import LuminosityFunction +from lfkit.luminosity_functions.registry import ( + CONDITIONAL_LF_MODELS, + get_conditional_lf_model, +) +from lfkit.utils.types import FloatArray, FloatInput __all__ = ["ConditionalLuminosityFunction"] -def _make_conditional_lf( - *, - model: str, - parameters: Mapping[str, object], - meta: Mapping[str, object] | None, -) -> LuminosityFunction: - """Create a LuminosityFunction backed by a conditional LF model.""" - return LuminosityFunction( - model=model, - parameters=parameters, - meta=meta, - ) +class ConditionalLuminosityFunction(LuminosityFunction): + """User-facing wrapper for conditional luminosity function models. + A conditional luminosity function evaluates ``Phi(M | x)``, where + ``M`` is absolute magnitude and ``x`` is an external conditioning + variable such as redshift, halo mass, or another model-specific quantity. -class ConditionalLuminosityFunction: - """Factory namespace for conditional luminosity function models.""" + Instances can be created either with the generic constructor or with + automatically generated model constructors. - @staticmethod - def schechter( - *, - phi_star: ConditionalParameter, - m_star: ConditionalParameter, - alpha: ConditionalParameter, - meta: Mapping[str, object] | None = None, - ) -> LuminosityFunction: - """Create a conditional Schechter luminosity function.""" - return _make_conditional_lf( - model="conditional_schechter", - parameters={ - "phi_star": phi_star, - "m_star": m_star, - "alpha": alpha, - }, - meta=meta, - ) + Examples: + >>> clf = ConditionalLuminosityFunction( + ... model="schechter", + ... parameters={"phi_star": 1e-3, "m_star": -20.5, "alpha": -1.1}, + ... ) + >>> phi = clf.phi(absolute_mag=-20.0, condition=0.5) - @staticmethod - def evolving_schechter( - *, - phi_model: str = "linear_p", - phi_kwargs: Mapping[str, ParameterValue] | None = None, - m_star_model: str = "linear_q", - m_star_kwargs: Mapping[str, ParameterValue] | None = None, - alpha_model: str = "constant", - alpha_kwargs: Mapping[str, ParameterValue] | None = None, - meta: Mapping[str, object] | None = None, - ) -> LuminosityFunction: - """Create a conditional evolving Schechter luminosity function.""" - return _make_conditional_lf( - model="conditional_evolving_schechter", - parameters={ - "phi_model": phi_model, - "phi_kwargs": {} if phi_kwargs is None else dict(phi_kwargs), - "m_star_model": m_star_model, - "m_star_kwargs": {} if m_star_kwargs is None else dict(m_star_kwargs), - "alpha_model": alpha_model, - "alpha_kwargs": {} if alpha_kwargs is None else dict(alpha_kwargs), - }, - meta=meta, - ) + >>> ConditionalLuminosityFunction.available_models() + """ - @staticmethod - def double_schechter( - *, - phi_star: ConditionalParameter, - m_star: ConditionalParameter, - alpha: float, - beta: float, - m_transition: ConditionalParameter, - meta: Mapping[str, object] | None = None, - ) -> LuminosityFunction: - """Create a conditional double-power-law Schechter luminosity function.""" - return _make_conditional_lf( - model="conditional_double_schechter", - parameters={ - "phi_star": phi_star, - "m_star": m_star, - "alpha": alpha, - "beta": beta, - "m_transition": m_transition, - }, - meta=meta, - ) + def phi( + self, + absolute_mag: FloatInput, + condition: FloatInput | None = None, + ) -> FloatArray: + """Evaluate the conditional luminosity function. - @staticmethod - def lognormal( - *, - mean_absolute_mag: ConditionalParameter, - sigma_log_luminosity: ConditionalParameter, - amplitude: ConditionalParameter = 1.0, - meta: Mapping[str, object] | None = None, - ) -> LuminosityFunction: - """Create a lognormal conditional luminosity function.""" - return _make_conditional_lf( - model="lognormal_conditional_lf", - parameters={ - "mean_absolute_mag": mean_absolute_mag, - "sigma_log_luminosity": sigma_log_luminosity, - "amplitude": amplitude, - }, - meta=meta, - ) + Args: + absolute_mag: Absolute magnitude value or array. + condition: Conditioning variable value or array. The meaning of + this variable depends on the selected conditional LF model. - @staticmethod - def modified_schechter( - *, - phi_star: ConditionalParameter, - m_star: ConditionalParameter, - alpha: ConditionalParameter, - meta: Mapping[str, object] | None = None, - ) -> LuminosityFunction: - """Create a modified Schechter conditional luminosity function.""" - return _make_conditional_lf( - model="modified_schechter_conditional_lf", - parameters={ - "phi_star": phi_star, - "m_star": m_star, - "alpha": alpha, - }, - meta=meta, + Returns: + Conditional luminosity function evaluated at ``absolute_mag`` and + ``condition``. + + Raises: + ValueError: If ``condition`` is not provided or the model is not + registered as a conditional luminosity function. + """ + model_spec = get_conditional_lf_model(self.model) + + if condition is None: + raise ValueError( + f"condition is required for conditional luminosity function " + f"model '{self.model}'." + ) + + return model_spec.function( + np.asarray(absolute_mag, dtype=float), + np.asarray(condition, dtype=float), + **self.parameters_dict, ) @staticmethod - def two_component( + def available_models() -> list[str]: + """Return conditional luminosity function model names.""" + return sorted(CONDITIONAL_LF_MODELS) + + +def _make_conditional_constructor( + *, + model_name: str, + function: Any, +): + """Create a classmethod constructor from a registered conditional LF model.""" + signature = inspect.signature(function) + + @classmethod + def constructor( + cls, *, - lognormal_mean_absolute_mag: ConditionalParameter, - lognormal_sigma_log_luminosity: ConditionalParameter, - modified_phi_star: ConditionalParameter, - modified_alpha: ConditionalParameter, - lognormal_amplitude: ConditionalParameter = 1.0, - modified_m_star: ConditionalParameter | None = None, - modified_luminosity_fraction: ConditionalParameter = 0.562, meta: Mapping[str, object] | None = None, - ) -> LuminosityFunction: - """Create a two-component conditional luminosity function.""" - return _make_conditional_lf( - model="two_component_conditional_lf", - parameters={ - "lognormal_mean_absolute_mag": lognormal_mean_absolute_mag, - "lognormal_sigma_log_luminosity": lognormal_sigma_log_luminosity, - "lognormal_amplitude": lognormal_amplitude, - "modified_phi_star": modified_phi_star, - "modified_alpha": modified_alpha, - "modified_m_star": modified_m_star, - "modified_luminosity_fraction": modified_luminosity_fraction, - }, + **parameters: Any, + ) -> ConditionalLuminosityFunction: + """Create a conditional luminosity function from model parameters.""" + return cls( + model=model_name, + parameters=_parameters_from_signature( + signature=signature, + provided=parameters, + ), meta=meta, ) + + constructor.__name__ = model_name + constructor.__qualname__ = f"ConditionalLuminosityFunction.{model_name}" + constructor.__doc__ = f"""Create a ``{model_name}`` conditional luminosity function. + +The keyword arguments are inferred from the registered low-level model +function. Required model parameters must be supplied by the user. Optional +model parameters use their low-level defaults unless explicitly provided. + +Args: + meta: Optional metadata stored on the luminosity function object. + **parameters: Parameters passed to the registered conditional LF model. + +Returns: + ConditionalLuminosityFunction: Configured conditional luminosity function. + +Examples: + >>> clf = ConditionalLuminosityFunction.{model_name}(...) + >>> phi = clf.phi(absolute_mag=-20.0, condition=0.5) +""" + + return constructor + + +def _parameters_from_signature( + *, + signature: inspect.Signature, + provided: Mapping[str, Any], +) -> dict[str, Any]: + """Build stored parameters using function defaults plus user values. + + Independent variables such as ``absolute_mag`` and ``condition`` are not + stored as model parameters. They are supplied later when calling + :meth:`ConditionalLuminosityFunction.phi`. + + Args: + signature: Signature of the registered low-level conditional LF model. + provided: User-supplied constructor keyword arguments. + + Returns: + Dictionary of model parameters to store on the API object. + + Raises: + TypeError: If the user provides a keyword that is not accepted by the + registered low-level model function. + """ + payload: dict[str, Any] = {} + + for name, parameter in signature.parameters.items(): + if name in {"absolute_mag", "condition", "z", "redshift", "x"}: + continue + + if parameter.kind in { + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + }: + continue + + if name in provided: + payload[name] = provided[name] + elif parameter.default is not inspect.Parameter.empty: + payload[name] = parameter.default + + extra = set(provided) - set(signature.parameters) + if extra: + raise TypeError(f"Unexpected parameter(s): {sorted(extra)}") + + return payload + + +for _model_name, _model_spec in CONDITIONAL_LF_MODELS.items(): + setattr( + ConditionalLuminosityFunction, + _model_name, + _make_conditional_constructor( + model_name=_model_name, + function=_model_spec.function, + ), + ) + +del _model_name, _model_spec diff --git a/src/lfkit/api/luminosity_function.py b/src/lfkit/api/luminosity_function.py index 0aa5fa94..e7042022 100644 --- a/src/lfkit/api/luminosity_function.py +++ b/src/lfkit/api/luminosity_function.py @@ -1,39 +1,37 @@ -r"""Public luminosity function interface. - -This module provides the user-facing :class:`LuminosityFunction` API for -evaluating luminosity functions in absolute- or apparent magnitude space. - -The class stores luminosity function model state and exposes grouped API -namespaces for related calculations. Low-level numerical and photometric -work remains in the function-based ``lfkit.photometry`` modules. -""" +"""Public luminosity function interface.""" from __future__ import annotations from collections.abc import Mapping -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import numpy as np -from lfkit.api._lf_param_models import LF_FROM_M_MODELS, LF_MODELS -from lfkit.api._completeness import LFCompletenessAPI -from lfkit.api._integrals import LFIntegralsAPI -from lfkit.api._luminosities import LFLuminositiesAPI -from lfkit.api._magnitudes import LFMagnitudesAPI -from lfkit.photometry.lf_parameter_models import ( +from lfkit.api._namespaces import ( + LFCompletenessAPI, + LFIntegralsAPI, + LFLuminositiesAPI, + LFMagnitudesAPI, + LFRedshiftDensityAPI, +) +from lfkit.luminosity_functions.parameter_models import ( available_lf_parameter_models, evaluate_lf_parameters, register_alpha_model, register_m_star_model, register_phi_star_model, ) -from lfkit.api._redshift_density import LFRedshiftDensityAPI +from lfkit.luminosity_functions.registry import ( + LF_FROM_M_MODELS, + LF_MODELS, + get_lf_from_m_model, + get_lf_model, +) from lfkit.utils.types import ( Cosmology, FloatArray, FloatInput, ParameterModel, - ParameterValue, ) if TYPE_CHECKING: @@ -48,10 +46,29 @@ class LuminosityFunction: """User-facing wrapper for luminosity function evaluation. - Args: - model: Name of the luminosity function model. - parameters: Model parameters passed to the underlying LF function. - meta: Optional metadata describing the LF source or calibration. + A luminosity function describes the number density of galaxies as a + function of absolute magnitude. This class stores a registered LF model and + its parameters, then exposes a consistent user-facing interface for model + evaluation, apparent-magnitude evaluation, integrals, completeness + calculations, redshift-density calculations, and magnitude/luminosity + conversions. + + Instances can be created either with the generic constructor or with + automatically generated model constructors. + + Examples: + >>> lf = LuminosityFunction( + ... model="schechter", + ... parameters={"phi_star": 1e-3, "m_star": -20.5, "alpha": -1.1}, + ... ) + >>> phi = lf.phi(absolute_mag=-20.0) + + >>> lf = LuminosityFunction.schechter( + ... phi_star=1e-3, + ... m_star=-20.5, + ... alpha=-1.1, + ... ) + >>> phi = lf.phi(absolute_mag=[-21.0, -20.0, -19.0]) """ def __init__( @@ -61,6 +78,13 @@ def __init__( parameters: Mapping[str, object], meta: Mapping[str, object] | None = None, ) -> None: + """Create a luminosity function object. + + Args: + model: Name of a registered luminosity function model. + parameters: Model parameters passed to the registered LF function. + meta: Optional metadata stored on the luminosity function object. + """ self.model = str(model) self.parameters_dict = dict(parameters) self.meta = {} if meta is None else dict(meta) @@ -71,148 +95,47 @@ def __init__( self.luminosities = LFLuminositiesAPI() self.magnitudes = LFMagnitudesAPI() - @classmethod - def schechter( - cls, - *, - phi_star: ParameterValue, - m_star: ParameterValue, - alpha: ParameterValue, - meta: Mapping[str, object] | None = None, - ) -> LuminosityFunction: - """Create a standard Schechter luminosity function. - - Args: - phi_star: Normalization of the luminosity function. - m_star: Characteristic absolute magnitude. - alpha: Faint-end slope. - meta: Optional metadata describing the LF source or calibration. - - Returns: - Luminosity-function API object using the standard Schechter model. - """ - return cls( - model="schechter", - parameters={ - "phi_star": phi_star, - "m_star": m_star, - "alpha": alpha, - }, - meta=meta, - ) - - @classmethod - def evolving_schechter( - cls, - *, - phi_model: str = "linear_p", - phi_kwargs: Mapping[str, ParameterValue] | None = None, - m_star_model: str = "linear_q", - m_star_kwargs: Mapping[str, ParameterValue] | None = None, - alpha_model: str = "constant", - alpha_kwargs: Mapping[str, ParameterValue] | None = None, - meta: Mapping[str, object] | None = None, - ) -> LuminosityFunction: - """Create a redshift-evolving Schechter luminosity function. - - Args: - phi_model: Parameter model used for the normalization evolution. - phi_kwargs: Keyword arguments for the normalization model. - m_star_model: Parameter model used for characteristic-magnitude evolution. - m_star_kwargs: Keyword arguments for the characteristic-magnitude model. - alpha_model: Parameter model used for faint-end-slope evolution. - alpha_kwargs: Keyword arguments for the faint-end-slope model. - meta: Optional metadata describing the LF source or calibration. - - Returns: - Luminosity-function API object using an evolving Schechter model. - """ - return cls( - model="evolving_schechter", - parameters={ - "phi_model": phi_model, - "phi_kwargs": {} if phi_kwargs is None else dict(phi_kwargs), - "m_star_model": m_star_model, - "m_star_kwargs": {} if m_star_kwargs is None else dict(m_star_kwargs), - "alpha_model": alpha_model, - "alpha_kwargs": {} if alpha_kwargs is None else dict(alpha_kwargs), - }, - meta=meta, - ) - - @classmethod - def double_schechter( - cls, - *, - phi_star: ParameterValue, - m_star: ParameterValue, - alpha: float, - beta: float, - m_transition: ParameterValue, - meta: Mapping[str, object] | None = None, - ) -> LuminosityFunction: - """Create a double-power-law Schechter luminosity function. - - Args: - phi_star: Normalization of the luminosity function. - m_star: Characteristic absolute magnitude. - alpha: Bright-end or main Schechter slope. - beta: Additional slope controlling the second power-law component. - m_transition: Transition magnitude for the second component. - meta: Optional metadata describing the LF source or calibration. - - Returns: - Luminosity-function API object using the double Schechter model. - """ - return cls( - model="double_schechter", - parameters={ - "phi_star": phi_star, - "m_star": m_star, - "alpha": alpha, - "beta": beta, - "m_transition": m_transition, - }, - meta=meta, - ) - def phi( self, absolute_mag: FloatInput, z: FloatInput | None = None, ) -> FloatArray: - """Evaluate the luminosity function in absolute magnitude space. + """Evaluate the luminosity function in absolute-magnitude space. Args: - absolute_mag: Absolute magnitude values where the LF is evaluated. - z: Redshift or conditional-coordinate values. Required for evolving - and conditional models. + absolute_mag: Absolute magnitude value or array. + z: Optional redshift value or array. This is required only for + registered models whose parameters evolve with redshift. Returns: - Luminosity-function values evaluated at the input magnitudes. + Luminosity function evaluated at ``absolute_mag``. For redshift- + dependent models, the result is evaluated at ``absolute_mag`` and + ``z``. + + Raises: + ValueError: If the model is not registered, or if ``z`` is required + by the selected model but not provided. """ - try: - model_spec = LF_MODELS[self.model] - except KeyError as exc: - raise ValueError( - f"Unsupported luminosity function model '{self.model}'." - ) from exc + model_spec = get_lf_model(self.model) absolute_mag_arr = np.asarray(absolute_mag, dtype=float) - if model_spec["requires_z"]: + if hasattr(self, "_custom_phi"): + return self._custom_phi(absolute_mag_arr, z) + + if model_spec.requires_z: if z is None: raise ValueError( f"z is required for luminosity function model '{self.model}'." ) - return model_spec["function"]( + return model_spec.function( absolute_mag_arr, np.asarray(z, dtype=float), **self.parameters_dict, ) - return model_spec["function"]( + return model_spec.function( absolute_mag_arr, **self.parameters_dict, ) @@ -228,27 +151,26 @@ def phi_from_m( ) -> FloatArray: """Evaluate the luminosity function from apparent magnitudes. - Apparent magnitudes are converted to absolute magnitudes using the - supplied cosmology, optional reduced Hubble parameter, and optional - k- and e-correction model. + This converts apparent magnitude to absolute magnitude using the + supplied cosmology and redshift, then evaluates the registered LF model. Args: - cosmo_obj: Cosmology object used for distance-modulus conversion. - z: Redshift values. - apparent_mag: Apparent magnitude values. - h: Optional reduced Hubble parameter used in the magnitude conversion. - corrections: Optional object providing k-correction and e-correction values. + cosmo_obj: Cosmology object used for the distance conversion. + z: Redshift value or array. + apparent_mag: Apparent magnitude value or array. + h: Optional dimensionless Hubble parameter override. + corrections: Optional correction object with ``k(z)`` and ``e(z)`` + methods. Returns: - Luminosity-function values evaluated from apparent magnitudes. + Luminosity function evaluated at the absolute magnitudes implied by + ``apparent_mag`` and ``z``. + + Raises: + ValueError: If the selected LF model does not provide a + ``phi_from_m`` evaluator. """ - try: - function = LF_FROM_M_MODELS[self.model] - except KeyError as exc: - raise ValueError( - f"phi_from_m is not defined for luminosity function model " - f"'{self.model}'." - ) from exc + function = get_lf_from_m_model(self.model) k_corr, e_corr = self._correction_values(corrections, z) @@ -269,10 +191,13 @@ def parameters( """Evaluate evolving Schechter parameters at redshift. Args: - z: Redshift values where the evolving LF parameters are evaluated. + z: Redshift value or array. Returns: Tuple containing ``phi_star(z)``, ``m_star(z)``, and ``alpha(z)``. + + Raises: + ValueError: If the current model is not ``"evolving_schechter"``. """ if self.model != "evolving_schechter": raise ValueError("parameters(z) is only defined for evolving_schechter.") @@ -286,6 +211,45 @@ def _as_callable(self): """Return this object as an ``lf(M, z)`` callable.""" return lambda absolute_mag, z: self.phi(absolute_mag, z) + def with_luminosity_cutoff( + self, + *, + m_star: float | None = None, + cutoff_power: float = 2.0, + cutoff_amplitude: float = 1.0, + meta: Mapping[str, object] | None = None, + ) -> LuminosityFunction: + """Return a copy of this luminosity function with a luminosity cutoff.""" + cutoff_m_star = ( + self.parameters_dict["m_star"] + if m_star is None and "m_star" in self.parameters_dict + else m_star + ) + + if cutoff_m_star is None: + raise ValueError( + "m_star must be supplied when the base luminosity function " + "does not have an m_star parameter." + ) + + new = LuminosityFunction( + model=self.model, + parameters=self.parameters_dict, + meta={**self.meta, **({} if meta is None else dict(meta))}, + ) + + def modified_phi( + absolute_mag: FloatInput, + z: FloatInput | None = None, + ) -> FloatArray: + absolute_mag_arr = np.asarray(absolute_mag, dtype=float) + x = self.luminosities.ratio(absolute_mag_arr, cutoff_m_star) + modifier = np.exp(-cutoff_amplitude * x**cutoff_power) + return self.phi(absolute_mag_arr, z) * modifier + + new._custom_phi = modified_phi + return new + @staticmethod def available_models() -> list[str]: """Return luminosity function model names available through the API.""" @@ -293,12 +257,17 @@ def available_models() -> list[str]: @staticmethod def available_from_m_models() -> list[str]: - """Return models that support apparent magnitude evaluation.""" + """Return models that support apparent-magnitude evaluation.""" return sorted(LF_FROM_M_MODELS) @staticmethod def available_parameter_models() -> dict[str, list[str]]: - """Return available LF parameter evolution models.""" + """Return available LF parameter evolution models. + + Returns: + Dictionary mapping each LF parameter name to the registered + evolution models available for that parameter. + """ return available_lf_parameter_models() @staticmethod @@ -308,12 +277,12 @@ def register_phi_star_model( *, overwrite: bool = False, ) -> None: - """Register a phi-star evolution model. + """Register a ``phi_star`` evolution model. Args: - name: Name used to identify the model. - model: Callable evaluating ``phi_star(z)``. - overwrite: If True, replace an existing model with the same name. + name: Name used to identify the parameter model. + model: Callable or parameter model object. + overwrite: Whether to replace an existing model with the same name. """ register_phi_star_model(name, model, overwrite=overwrite) @@ -324,12 +293,12 @@ def register_m_star_model( *, overwrite: bool = False, ) -> None: - """Register an M-star evolution model. + """Register an ``m_star`` evolution model. Args: - name: Name used to identify the model. - model: Callable evaluating ``M_star(z)``. - overwrite: If True, replace an existing model with the same name. + name: Name used to identify the parameter model. + model: Callable or parameter model object. + overwrite: Whether to replace an existing model with the same name. """ register_m_star_model(name, model, overwrite=overwrite) @@ -340,12 +309,12 @@ def register_alpha_model( *, overwrite: bool = False, ) -> None: - """Register an alpha evolution model. + """Register an ``alpha`` evolution model. Args: - name: Name used to identify the model. - model: Callable evaluating ``alpha(z)``. - overwrite: If True, replace an existing model with the same name. + name: Name used to identify the parameter model. + model: Callable or parameter model object. + overwrite: Whether to replace an existing model with the same name. """ register_alpha_model(name, model, overwrite=overwrite) @@ -354,17 +323,77 @@ def _correction_values( corrections: Corrections | None, z: FloatInput, ) -> tuple[FloatArray | None, FloatArray | None]: - """Evaluate optional correction values at redshift. - - Args: - corrections: Optional correction object with ``k(z)`` and ``e(z)`` methods. - z: Redshift values where corrections are evaluated. - - Returns: - Tuple of k-correction and e-correction arrays, or ``None`` values - when no correction object is supplied. - """ + """Evaluate optional correction values at redshift.""" if corrections is None: return None, None return corrections.k(z), corrections.e(z) + + +def _make_lf_constructor(model_name: str): + """Create a classmethod constructor for a registered LF model.""" + + @classmethod + def constructor( + cls, + *, + meta: Mapping[str, object] | None = None, + **parameters: Any, + ) -> LuminosityFunction: + """Create a luminosity function from model parameters.""" + return cls( + model=model_name, + parameters=_clean_parameters(parameters), + meta=meta, + ) + + constructor.__name__ = model_name + constructor.__qualname__ = f"LuminosityFunction.{model_name}" + constructor.__doc__ = f"""Create a ``{model_name}`` luminosity function. + +The keyword arguments are passed to the registered low-level LF model. +Required model parameters must be supplied by the user. Optional model +parameters use their low-level defaults unless explicitly provided. + +Args: + meta: Optional metadata stored on the luminosity function object. + **parameters: Parameters passed to the registered LF model. + +Returns: + LuminosityFunction: Configured luminosity function. + +Examples: + >>> lf = LuminosityFunction.{model_name}(...) + >>> phi = lf.phi(absolute_mag=-20.0) +""" + + return constructor + + +def _clean_parameters(parameters: Mapping[str, Any]) -> dict[str, Any]: + """Normalize constructor keyword arguments before storing them. + + ``None`` values for keyword arguments ending in ``"_kwargs"`` are converted + to empty dictionaries. This keeps optional nested configuration arguments + convenient for users while storing a predictable parameter dictionary. + + Args: + parameters: Raw constructor keyword arguments. + + Returns: + Normalized parameter dictionary. + """ + return { + key: {} if value is None and key.endswith("_kwargs") else value + for key, value in parameters.items() + } + + +for _model_name in LF_MODELS: + setattr( + LuminosityFunction, + _model_name, + _make_lf_constructor(_model_name), + ) + +del _model_name diff --git a/src/lfkit/cosmo/cosmology.py b/src/lfkit/cosmo/cosmology.py index 914ec52f..ba3e1933 100644 --- a/src/lfkit/cosmo/cosmology.py +++ b/src/lfkit/cosmo/cosmology.py @@ -33,7 +33,7 @@ "differential_comoving_volume", ) -C_KM_S = 299792.458 +C_KM_S = 299792.458 # speed of light in vacuum in km/s def cosmo_object( diff --git a/src/lfkit/luminosity_functions/__init__.py b/src/lfkit/luminosity_functions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/lfkit/luminosity_functions/_discovery.py b/src/lfkit/luminosity_functions/_discovery.py new file mode 100644 index 00000000..e75a7fb0 --- /dev/null +++ b/src/lfkit/luminosity_functions/_discovery.py @@ -0,0 +1,36 @@ +"""Shared luminosity-function discovery helpers.""" + +from __future__ import annotations + +import importlib +import pkgutil +from collections.abc import Callable + +import lfkit.luminosity_functions.models as models_pkg + + +_SKIP_MODULES = { + "modifiers", +} + + +def iter_model_functions() -> dict[str, Callable]: + """Return public callable LF models discovered from ``models/``.""" + functions: dict[str, Callable] = {} + + for module_info in pkgutil.iter_modules(models_pkg.__path__): + if module_info.name.startswith("_"): + continue + + if module_info.name in _SKIP_MODULES: + continue + + module = importlib.import_module(f"{models_pkg.__name__}.{module_info.name}") + + for name in getattr(module, "__all__", []): + obj = getattr(module, name) + + if callable(obj): + functions[name] = obj + + return functions diff --git a/src/lfkit/photometry/catalog_completeness.py b/src/lfkit/luminosity_functions/completeness.py similarity index 98% rename from src/lfkit/photometry/catalog_completeness.py rename to src/lfkit/luminosity_functions/completeness.py index 223b1e8c..71bd68f4 100644 --- a/src/lfkit/photometry/catalog_completeness.py +++ b/src/lfkit/luminosity_functions/completeness.py @@ -22,7 +22,7 @@ import numpy as np -from lfkit.photometry.lf_integrals import ( +from lfkit.luminosity_functions.integrals import ( integrated_number_density as _integrated_number_density, ) from lfkit.photometry.magnitudes import absolute_magnitude @@ -39,7 +39,7 @@ "absolute_magnitude_limit", "observed_number_density", "missing_number_density", - "catalog_completeness_fraction", + "catalog_fraction", "out_of_catalog_fraction", ] @@ -221,7 +221,7 @@ def missing_number_density( ) -def catalog_completeness_fraction( +def catalog_fraction( cosmo_obj: Cosmology, z: FloatInput, lf: LuminosityFunction, @@ -326,7 +326,7 @@ def out_of_catalog_fraction( Returns: NumPy array of out-of-catalog fractions. """ - completeness = catalog_completeness_fraction( + completeness = catalog_fraction( cosmo_obj, z, lf, diff --git a/src/lfkit/photometry/conditional_lf_integrals.py b/src/lfkit/luminosity_functions/conditional_integrals.py similarity index 100% rename from src/lfkit/photometry/conditional_lf_integrals.py rename to src/lfkit/luminosity_functions/conditional_integrals.py diff --git a/src/lfkit/luminosity_functions/conditional_models.py b/src/lfkit/luminosity_functions/conditional_models.py new file mode 100644 index 00000000..93f2ed9b --- /dev/null +++ b/src/lfkit/luminosity_functions/conditional_models.py @@ -0,0 +1,83 @@ +"""Conditional luminosity function model utilities. + +This module provides generic conditional wrappers around LFKit luminosity +function models. + +A conditional luminosity function has the form ``Phi(M | x)``, where ``M`` is +absolute magnitude and ``x`` is an external conditioning variable. +""" + +from __future__ import annotations + +from collections.abc import Callable +from functools import wraps +from typing import Any + +import numpy as np + +from lfkit.luminosity_functions._discovery import iter_model_functions +from lfkit.utils.types import FloatArray, FloatInput +from lfkit.utils.validators import validate_array + + +def conditionalize_lf_model( + lf_model: Callable[..., FloatArray], +) -> Callable[..., FloatArray]: + """Return a conditional version of an LF model. + + Keyword arguments that are callable are evaluated as functions of + ``condition``. Non-callable keyword arguments are passed through unchanged. + """ + + @wraps(lf_model) + def conditional_model( + absolute_mag: FloatInput, + condition: FloatInput, + **kwargs: Any, + ) -> FloatArray: + condition_arr = validate_array(condition, name="condition") + + evaluated_kwargs: dict[str, Any] = {} + for name, value in kwargs.items(): + if callable(value): + evaluated_kwargs[name] = validate_array( + value(condition_arr), + name=name, + ) + else: + evaluated_kwargs[name] = value + + phi = lf_model(absolute_mag, **evaluated_kwargs) + return _validate_lf_output(phi, name=lf_model.__name__) + + return conditional_model + + +def _conditional_model_name(name: str) -> str: + """Return conditional wrapper name for an LF model.""" + return f"conditional_{name}" + + +def _validate_lf_output( + phi: FloatInput, + *, + name: str, +) -> FloatArray: + """Validate luminosity function model output.""" + phi_arr = validate_array(phi, name=name) + + if np.any(phi_arr < 0.0): + raise ValueError(f"{name} returned negative values, which are not allowed.") + + return np.asarray(phi_arr, dtype=np.float64) + + +__all__ = ["conditionalize_lf_model"] + +for _name, _function in iter_model_functions().items(): + if _name.endswith("_from_m"): + continue + + _conditional_name = _conditional_model_name(_name) + globals()[_conditional_name] = conditionalize_lf_model(_function) + __all__.append(_conditional_name) diff --git a/src/lfkit/photometry/lf_integrals.py b/src/lfkit/luminosity_functions/integrals.py similarity index 64% rename from src/lfkit/photometry/lf_integrals.py rename to src/lfkit/luminosity_functions/integrals.py index c2309866..fa5612fa 100644 --- a/src/lfkit/photometry/lf_integrals.py +++ b/src/lfkit/luminosity_functions/integrals.py @@ -1,4 +1,4 @@ -r"""Luminosity-function integration utilities. +"""Luminosity-function integration utilities. This module provides generic numerical integrals of luminosity function callables over finite absolute magnitude ranges. @@ -11,14 +11,21 @@ together. This keeps the integration machinery independent of any specific luminosity function parameterization, catalog selection, or cosmology backend. +The helper ``_bind_lf`` converts model functions with fixed parameters into +this common callable form. Static luminosity functions that do not depend on +redshift can be wrapped with ``_bind_static_lf``. + These helpers are intentionally generic. Catalog completeness, LF-dependent -redshift densities, luminosity-density calculations, and selection-weighted -integrals can all call this module instead of duplicating magnitude-grid logic. +redshift densities, luminosity-density calculations, selection fractions, and +selection-weighted integrals can all call this module instead of duplicating +magnitude-grid logic. """ from __future__ import annotations from collections.abc import Callable +from functools import wraps +from typing import Any import numpy as np @@ -41,8 +48,106 @@ "mean_luminosity", "cumulative_number_density", "magnitude_window_number_density", + "selection_fraction", + "cumulative_selection_function", + "luminosity_weight", ] +__api_aliases__ = { + "integrated_number_density": "number_density", + "lf_weighted_integral": "weighted", + "selection_weighted_number_density": "selection_weighted_number_density", + "integrated_luminosity_density": "luminosity_density", + "mean_luminosity": "mean_luminosity", + "cumulative_number_density": "cumulative_number_density", + "magnitude_window_number_density": "magnitude_window_number_density", + "selection_fraction": "selection_fraction", + "cumulative_selection_function": "selection_function", + "luminosity_weight": "luminosity_weight", +} + + +def _bind_lf( + model_fn: Callable[..., FloatArray], + /, + **params: Any, +) -> LuminosityFunction: + """Bind parameters to a redshift-dependent LF model. + + This converts a model function with signature approximately + + ``model_fn(absolute_mag, z, **params)`` + + into the common integration signature + + ``lf(absolute_mag, z)``. + + Args: + model_fn: Luminosity-function model callable. + **params: Parameters passed to ``model_fn`` every time it is evaluated. + + Returns: + Callable with signature ``lf(absolute_mag, z)``. + + Examples: + >>> lf = _bind_lf( + ... evolving_schechter, + ... phi_model="linear_p", + ... phi_kwargs={"phi0": 1e-3, "p": 1.0}, + ... m_star_model="linear_q", + ... m_star_kwargs={"m0": -21.0, "q": 1.0}, + ... alpha_model="constant", + ... alpha_kwargs={"alpha0": -0.9}, + ... ) + """ + + @wraps(model_fn) + def lf(absolute_mag: FloatArray, z: FloatArray) -> FloatArray: + return np.asarray(model_fn(absolute_mag, z, **params), dtype=float) + + return lf + + +def _bind_static_lf( + model_fn: Callable[..., FloatArray], + /, + **params: Any, +) -> LuminosityFunction: + """Bind parameters to a redshift-independent LF model. + + This converts a static model function with signature approximately + + ``model_fn(absolute_mag, **params)`` + + into the common integration signature + + ``lf(absolute_mag, z)``. + + The redshift argument is accepted but ignored. This lets static and + evolving luminosity functions use the same integration API. + + Args: + model_fn: Redshift-independent luminosity-function model callable. + **params: Parameters passed to ``model_fn`` every time it is evaluated. + + Returns: + Callable with signature ``lf(absolute_mag, z)``. + + Examples: + >>> lf = _bind_static_lf( + ... schechter, + ... phi_star=1e-3, + ... m_star=-21.0, + ... alpha=-0.9, + ... ) + """ + + @wraps(model_fn) + def lf(absolute_mag: FloatArray, _z: FloatArray) -> FloatArray: + return np.asarray(model_fn(absolute_mag, **params), dtype=float) + + return lf + def integrated_number_density( z: FloatInput, @@ -91,7 +196,7 @@ def lf_weighted_integral( weight_fn: Callable[[FloatArray, FloatArray], FloatArray], n_m: int = 512, ) -> FloatArray: - r"""Return a weighted luminosity function integral. + r"""Return a weighted luminosity-function integral. This computes @@ -164,6 +269,40 @@ def selection_weighted_number_density( ) +def luminosity_weight( + absolute_mag: FloatInput, + _z: FloatInput | None = None, + *, + m_reference: float = 0.0, +) -> FloatArray: + r"""Return relative luminosity weights for absolute magnitudes. + + This evaluates + + .. math:: + + L(M) / L_{\mathrm{ref}} = + 10^{-0.4(M - M_{\mathrm{ref}})}. + + Args: + absolute_mag: Absolute magnitude value(s). + _z: Optional redshift argument, accepted for compatibility with + weight callables of signature ``weight_fn(M, z)``. + m_reference: Reference absolute magnitude defining the luminosity unit. + + Returns: + NumPy array of relative luminosity weights. + """ + if not np.isfinite(m_reference): + raise ValueError("m_reference must be finite.") + + absolute_mag_arr = validate_array(absolute_mag, name="absolute_mag") + weight = 10.0 ** (-0.4 * (absolute_mag_arr - m_reference)) + weight = np.clip(weight, 1e-300, 1e300) + + return np.asarray(weight, dtype=float) + + def integrated_luminosity_density( z: FloatInput, lf: LuminosityFunction, @@ -202,22 +341,23 @@ def integrated_luminosity_density( NumPy array of luminosity densities in units of the reference luminosity. """ - if not np.isfinite(m_reference): - raise ValueError("m_reference must be finite.") - def luminosity_weight( + def weight_fn( absolute_mag: FloatArray, - _redshift: FloatArray, + redshift: FloatArray, ) -> FloatArray: - """Return relative luminosity weights for absolute magnitudes.""" - return np.asarray(10.0 ** (-0.4 * (absolute_mag - m_reference)), dtype=float) + return luminosity_weight( + absolute_mag, + redshift, + m_reference=m_reference, + ) return lf_weighted_integral( z, lf, m_bright=m_bright, m_faint=m_faint, - weight_fn=luminosity_weight, + weight_fn=weight_fn, n_m=n_m, ) @@ -256,7 +396,7 @@ def mean_luminosity( NumPy array of mean luminosities. Entries are zero where the integrated number density is zero. """ - luminosity_density = integrated_luminosity_density( + luminosity_density_arr = integrated_luminosity_density( z, lf, m_bright=m_bright, @@ -264,7 +404,7 @@ def mean_luminosity( m_reference=m_reference, n_m=n_m, ) - number_density = integrated_number_density( + number_density_arr = integrated_number_density( z, lf, m_bright=m_bright, @@ -272,7 +412,7 @@ def mean_luminosity( n_m=n_m, ) - return safe_divide(luminosity_density, number_density) + return safe_divide(luminosity_density_arr, number_density_arr) def cumulative_number_density( @@ -344,6 +484,142 @@ def cumulative_number_density( ) +def selection_fraction( + z: FloatInput, + lf: LuminosityFunction, + *, + m_selected_bright: FloatInput, + m_selected_faint: FloatInput, + m_total_bright: FloatInput, + m_total_faint: FloatInput, + n_m: int = 512, +) -> FloatArray: + r"""Return the fraction of LF number density inside a selected window. + + This computes + + .. math:: + + f_{\mathrm{sel}}(z) = + \frac{ + \int_{M_{\mathrm{sel,bright}}}^{M_{\mathrm{sel,faint}}} + \phi(M,z)\,dM + }{ + \int_{M_{\mathrm{tot,bright}}}^{M_{\mathrm{tot,faint}}} + \phi(M,z)\,dM + }. + + This is the generic numerical analogue of model-specific analytic + selection functions. It works for any luminosity-function callable with + signature ``lf(M, z)``. + + Args: + z: Redshift value or array-like of redshift values. + lf: Luminosity-function callable with signature ``lf(M, z)``. + m_selected_bright: Bright bound of the selected magnitude window. + m_selected_faint: Faint bound of the selected magnitude window. + m_total_bright: Bright bound of the reference total window. + m_total_faint: Faint bound of the reference total window. + n_m: Number of magnitude-grid points used for each integral. + + Returns: + NumPy array of selected fractions. Entries are zero where the total + number density is zero. + """ + selected = integrated_number_density( + z, + lf, + m_bright=m_selected_bright, + m_faint=m_selected_faint, + n_m=n_m, + ) + total = integrated_number_density( + z, + lf, + m_bright=m_total_bright, + m_faint=m_total_faint, + n_m=n_m, + ) + + return safe_divide(selected, total) + + +def cumulative_selection_function( + z: FloatInput, + lf: LuminosityFunction, + *, + m_threshold: FloatInput, + m_bright: FloatInput, + m_faint: FloatInput, + brighter_than: bool = True, + n_m: int = 512, +) -> FloatArray: + r"""Return the cumulative LF selection fraction around a threshold. + + This computes the cumulative number density brighter or fainter than a + threshold divided by the total number density in the supplied reference + magnitude range. + + If ``brighter_than`` is True, + + .. math:: + + S(z) = + \frac{ + \int_{M_{\mathrm{bright}}}^{\min(M_{\mathrm{thr}},M_{\mathrm{faint}})} + \phi(M,z)\,dM + }{ + \int_{M_{\mathrm{bright}}}^{M_{\mathrm{faint}}} + \phi(M,z)\,dM + }. + + If ``brighter_than`` is False, + + .. math:: + + S(z) = + \frac{ + \int_{\max(M_{\mathrm{thr}},M_{\mathrm{bright}})}^{M_{\mathrm{faint}}} + \phi(M,z)\,dM + }{ + \int_{M_{\mathrm{bright}}}^{M_{\mathrm{faint}}} + \phi(M,z)\,dM + }. + + Args: + z: Redshift value or array-like of redshift values. + lf: Luminosity-function callable with signature ``lf(M, z)``. + m_threshold: Absolute magnitude threshold. + m_bright: Bright absolute magnitude bound of the reference window. + m_faint: Faint absolute magnitude bound of the reference window. + brighter_than: If True, return the brighter-than-threshold fraction. + If False, return the fainter-than-threshold fraction. + n_m: Number of magnitude-grid points used for each integral. + + Returns: + NumPy array of cumulative selection fractions. Entries are zero where + the total number density is zero. + """ + selected = cumulative_number_density( + z, + lf, + m_threshold=m_threshold, + m_bright=m_bright, + m_faint=m_faint, + brighter_than=brighter_than, + n_m=n_m, + ) + total = integrated_number_density( + z, + lf, + m_bright=m_bright, + m_faint=m_faint, + n_m=n_m, + ) + + return safe_divide(selected, total) + + def magnitude_window_number_density( z: FloatInput, lf: LuminosityFunction, diff --git a/src/lfkit/luminosity_functions/models/__init__.py b/src/lfkit/luminosity_functions/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/lfkit/luminosity_functions/models/composite.py b/src/lfkit/luminosity_functions/models/composite.py new file mode 100644 index 00000000..f02b8359 --- /dev/null +++ b/src/lfkit/luminosity_functions/models/composite.py @@ -0,0 +1,96 @@ +"""Composite luminosity function models.""" + +from __future__ import annotations + +from collections.abc import Callable + +import numpy as np + +from lfkit.luminosity_functions.models.gaussian import lognormal_lf +from lfkit.luminosity_functions.models.schechter import schechter +from lfkit.luminosity_functions.models.modifiers import apply_luminosity_cutoff +from lfkit.photometry.luminosities import magnitude_difference_from_luminosity_ratio +from lfkit.utils.types import FloatArray, FloatInput, ParameterValue +from lfkit.utils.validators import validate_array + + +__all__ = [ + "two_component_lf", +] + + +def additive_lf( + absolute_mag: FloatInput, + *components: Callable[[FloatInput], FloatArray], +) -> FloatArray: + """Return the sum of multiple luminosity function components.""" + absolute_mag_arr = validate_array(absolute_mag, name="absolute_mag") + + if len(components) == 0: + raise ValueError("At least one luminosity function component is required.") + + phi = np.zeros_like(absolute_mag_arr, dtype=float) + + for component in components: + phi = phi + component(absolute_mag_arr) + + return np.asarray(phi, dtype=float) + + +def two_component_lf( + absolute_mag: FloatInput, + *, + lognormal_mean_absolute_mag: ParameterValue, + lognormal_sigma_log_luminosity: ParameterValue, + modified_phi_star: ParameterValue, + modified_alpha: ParameterValue, + lognormal_amplitude: ParameterValue = 1.0, + modified_m_star: ParameterValue | None = None, + modified_luminosity_fraction: ParameterValue = 0.562, +) -> FloatArray: + """Return the sum of lognormal and modified Schechter components.""" + lognormal_mean_absolute_mag_arr = validate_array( + lognormal_mean_absolute_mag, + name="lognormal_mean_absolute_mag", + ) + + lognormal_phi = lognormal_lf( + absolute_mag, + mean_absolute_mag=lognormal_mean_absolute_mag_arr, + sigma_log_luminosity=lognormal_sigma_log_luminosity, + amplitude=lognormal_amplitude, + ) + + if modified_m_star is None: + modified_luminosity_fraction_arr = validate_array( + modified_luminosity_fraction, + name="modified_luminosity_fraction", + ) + + if np.any(modified_luminosity_fraction_arr <= 0.0): + raise ValueError("modified_luminosity_fraction must be positive.") + + modified_m_star_arr = lognormal_mean_absolute_mag_arr + ( + magnitude_difference_from_luminosity_ratio( + modified_luminosity_fraction_arr, + ) + ) + else: + modified_m_star_arr = validate_array( + modified_m_star, + name="modified_m_star", + ) + + modified_phi = apply_luminosity_cutoff( + absolute_mag, + base_lf=schechter, + phi_star=modified_phi_star, + m_star=modified_m_star_arr, + alpha=modified_alpha, + ) + + return additive_lf( + absolute_mag, + lambda mag: np.asarray(lognormal_phi, dtype=float), + lambda mag: np.asarray(modified_phi, dtype=float), + ) diff --git a/src/lfkit/luminosity_functions/models/gaussian.py b/src/lfkit/luminosity_functions/models/gaussian.py new file mode 100644 index 00000000..e659ba5d --- /dev/null +++ b/src/lfkit/luminosity_functions/models/gaussian.py @@ -0,0 +1,89 @@ +"""Gaussian-like luminosity function models.""" + +from __future__ import annotations + +import numpy as np + +from lfkit.utils.types import FloatArray, FloatInput, ParameterValue +from lfkit.utils.validators import validate_array + + +__all__ = [ + "gaussian_lf", + "lognormal_lf", +] + + +def gaussian_lf( + absolute_mag: FloatInput, + *, + mean_absolute_mag: ParameterValue, + sigma_absolute_mag: ParameterValue, + amplitude: ParameterValue = 1.0, +) -> FloatArray: + """Return a Gaussian luminosity function in magnitude space.""" + absolute_mag_arr = validate_array(absolute_mag, name="absolute_mag") + mean_absolute_mag_arr = validate_array( + mean_absolute_mag, + name="mean_absolute_mag", + ) + sigma_absolute_mag_arr = validate_array( + sigma_absolute_mag, + name="sigma_absolute_mag", + ) + amplitude_arr = validate_array(amplitude, name="amplitude") + + if np.any(sigma_absolute_mag_arr <= 0.0): + raise ValueError("sigma_absolute_mag must be positive.") + + if np.any(amplitude_arr < 0.0): + raise ValueError("amplitude must be non-negative.") + + phi = ( + amplitude_arr + / (np.sqrt(2.0 * np.pi) * sigma_absolute_mag_arr) + * np.exp( + -0.5 + * ((absolute_mag_arr - mean_absolute_mag_arr) / sigma_absolute_mag_arr) + ** 2.0 + ) + ) + + return np.asarray(phi, dtype=float) + + +def lognormal_lf( + absolute_mag: FloatInput, + *, + mean_absolute_mag: ParameterValue, + sigma_log_luminosity: ParameterValue, + amplitude: ParameterValue = 1.0, +) -> FloatArray: + """Return a lognormal luminosity function in magnitude space.""" + absolute_mag_arr = validate_array(absolute_mag, name="absolute_mag") + mean_absolute_mag_arr = validate_array( + mean_absolute_mag, + name="mean_absolute_mag", + ) + sigma_log_luminosity_arr = validate_array( + sigma_log_luminosity, + name="sigma_log_luminosity", + ) + amplitude_arr = validate_array(amplitude, name="amplitude") + + if np.any(sigma_log_luminosity_arr <= 0.0): + raise ValueError("sigma_log_luminosity must be positive.") + + if np.any(amplitude_arr < 0.0): + raise ValueError("amplitude must be non-negative.") + + delta_log_luminosity = -0.4 * (absolute_mag_arr - mean_absolute_mag_arr) + + phi = ( + amplitude_arr + * 0.4 + / (np.sqrt(2.0 * np.pi) * sigma_log_luminosity_arr) + * np.exp(-0.5 * (delta_log_luminosity / sigma_log_luminosity_arr) ** 2.0) + ) + + return np.asarray(phi, dtype=float) diff --git a/src/lfkit/luminosity_functions/models/modifiers.py b/src/lfkit/luminosity_functions/models/modifiers.py new file mode 100644 index 00000000..9708b682 --- /dev/null +++ b/src/lfkit/luminosity_functions/models/modifiers.py @@ -0,0 +1,52 @@ +"""Generic luminosity function modifiers.""" + +from __future__ import annotations + +from collections.abc import Callable + +import numpy as np + +from lfkit.photometry.luminosities import luminosity_ratio +from lfkit.utils.types import FloatArray, FloatInput, ParameterValue +from lfkit.utils.validators import validate_array + + +__all__ = [ + "apply_luminosity_cutoff", +] + + +def apply_luminosity_cutoff( + absolute_mag: FloatInput, + *, + base_lf: Callable[..., FloatArray], + m_star: ParameterValue, + cutoff_power: ParameterValue = 2.0, + cutoff_amplitude: ParameterValue = 1.0, + **base_lf_parameters: ParameterValue, +) -> FloatArray: + """Return a luminosity function multiplied by a luminosity-ratio cutoff.""" + absolute_mag_arr = validate_array(absolute_mag, name="absolute_mag") + cutoff_power_arr = validate_array(cutoff_power, name="cutoff_power") + cutoff_amplitude_arr = validate_array( + cutoff_amplitude, + name="cutoff_amplitude", + ) + + if np.any(cutoff_power_arr <= 0.0): + raise ValueError("cutoff_power must be positive.") + + if np.any(cutoff_amplitude_arr < 0.0): + raise ValueError("cutoff_amplitude must be non-negative.") + + x = luminosity_ratio(absolute_mag_arr, m_star) + + base_phi = base_lf( + absolute_mag_arr, + m_star=m_star, + **base_lf_parameters, + ) + + modifier = np.exp(-cutoff_amplitude_arr * x**cutoff_power_arr) + + return np.asarray(base_phi * modifier, dtype=float) diff --git a/src/lfkit/luminosity_functions/models/power_law.py b/src/lfkit/luminosity_functions/models/power_law.py new file mode 100644 index 00000000..a8a5fdb0 --- /dev/null +++ b/src/lfkit/luminosity_functions/models/power_law.py @@ -0,0 +1,115 @@ +"""Power-law luminosity function models.""" + +from __future__ import annotations + +import numpy as np + +from lfkit.photometry.luminosities import luminosity_ratio +from lfkit.utils.types import FloatArray, FloatInput, ParameterValue +from lfkit.utils.validators import validate_array + + +__all__ = [ + "power_law_lf", + "double_power_law_lf", + "broken_power_law_lf", + "log_power_law_lf", +] + + +def power_law_lf( + absolute_mag: FloatInput, + *, + phi_star: ParameterValue, + m_star: ParameterValue, + alpha: ParameterValue, +) -> FloatArray: + """Return a single power-law luminosity function.""" + absolute_mag_arr = validate_array(absolute_mag, name="absolute_mag") + phi_star_arr = validate_array(phi_star, name="phi_star") + alpha_arr = validate_array(alpha, name="alpha") + + if np.any(phi_star_arr < 0.0): + raise ValueError("phi_star must be non-negative.") + + x = luminosity_ratio(absolute_mag_arr, m_star) + + phi = 0.4 * np.log(10.0) * phi_star_arr * x ** (alpha_arr + 1.0) + + return np.asarray(phi, dtype=float) + + +def double_power_law_lf( + absolute_mag: FloatInput, + *, + phi_star: ParameterValue, + m_star: ParameterValue, + alpha: ParameterValue, + beta: ParameterValue, +) -> FloatArray: + """Return a double power-law luminosity function.""" + absolute_mag_arr = validate_array(absolute_mag, name="absolute_mag") + phi_star_arr = validate_array(phi_star, name="phi_star") + alpha_arr = validate_array(alpha, name="alpha") + beta_arr = validate_array(beta, name="beta") + + if np.any(phi_star_arr < 0.0): + raise ValueError("phi_star must be non-negative.") + + x = luminosity_ratio(absolute_mag_arr, m_star) + + phi = ( + 0.4 + * np.log(10.0) + * phi_star_arr + / (x ** (-(alpha_arr + 1.0)) + x ** (-(beta_arr + 1.0))) + ) + + return np.asarray(phi, dtype=float) + + +def broken_power_law_lf( + absolute_mag: FloatInput, + *, + phi_star: ParameterValue, + m_star: ParameterValue, + alpha_faint: ParameterValue, + alpha_bright: ParameterValue, +) -> FloatArray: + """Return a sharply broken power-law luminosity function.""" + absolute_mag_arr = validate_array(absolute_mag, name="absolute_mag") + phi_star_arr = validate_array(phi_star, name="phi_star") + alpha_faint_arr = validate_array(alpha_faint, name="alpha_faint") + alpha_bright_arr = validate_array(alpha_bright, name="alpha_bright") + + if np.any(phi_star_arr < 0.0): + raise ValueError("phi_star must be non-negative.") + + x = luminosity_ratio(absolute_mag_arr, m_star) + + phi = np.where( + x < 1.0, + phi_star_arr * x ** (alpha_faint_arr + 1.0), + phi_star_arr * x ** (alpha_bright_arr + 1.0), + ) + phi = 0.4 * np.log(10.0) * phi + + return np.asarray(phi, dtype=float) + + +def log_power_law_lf( + absolute_mag: FloatInput, + *, + log_phi_star: ParameterValue, + m_star: ParameterValue, + alpha: ParameterValue, +) -> FloatArray: + """Return a power-law luminosity function using log10 normalization.""" + phi_star = 10.0 ** validate_array(log_phi_star, name="log_phi_star") + + return power_law_lf( + absolute_mag, + phi_star=phi_star, + m_star=m_star, + alpha=alpha, + ) diff --git a/src/lfkit/photometry/luminosity_function.py b/src/lfkit/luminosity_functions/models/schechter.py similarity index 99% rename from src/lfkit/photometry/luminosity_function.py rename to src/lfkit/luminosity_functions/models/schechter.py index ed193e11..ba519825 100644 --- a/src/lfkit/photometry/luminosity_function.py +++ b/src/lfkit/luminosity_functions/models/schechter.py @@ -54,7 +54,7 @@ import numpy as np from scipy.special import gammaincc, gamma -from lfkit.photometry.lf_parameter_models import evaluate_lf_parameters +from lfkit.luminosity_functions.parameter_models import evaluate_lf_parameters from lfkit.photometry.luminosities import luminosity_ratio from lfkit.photometry.magnitudes import absolute_magnitude from lfkit.utils.types import Cosmology, FloatArray, FloatInput, ParameterValue diff --git a/src/lfkit/photometry/lf_parameter_models.py b/src/lfkit/luminosity_functions/parameter_models.py similarity index 100% rename from src/lfkit/photometry/lf_parameter_models.py rename to src/lfkit/luminosity_functions/parameter_models.py diff --git a/src/lfkit/photometry/lf_redshift_density.py b/src/lfkit/luminosity_functions/redshift_density.py similarity index 97% rename from src/lfkit/photometry/lf_redshift_density.py rename to src/lfkit/luminosity_functions/redshift_density.py index 1695a10f..1f442caf 100644 --- a/src/lfkit/photometry/lf_redshift_density.py +++ b/src/lfkit/luminosity_functions/redshift_density.py @@ -25,7 +25,7 @@ import numpy as np -from lfkit.photometry.lf_integrals import integrated_number_density +from lfkit.luminosity_functions.integrals import integrated_number_density from lfkit.photometry.magnitudes import absolute_magnitude_from_luminosity_distance from lfkit.utils.evaluators import ( evaluate_non_negative_redshift_callable, @@ -40,6 +40,11 @@ "lf_weighted_redshift_density", ] +__api_aliases__ = { + "lf_integrated_number_density": "integrated_number_density", + "lf_weighted_redshift_density": "weighted", +} + def lf_integrated_number_density( z: FloatInput, diff --git a/src/lfkit/luminosity_functions/registry.py b/src/lfkit/luminosity_functions/registry.py new file mode 100644 index 00000000..23cb67dd --- /dev/null +++ b/src/lfkit/luminosity_functions/registry.py @@ -0,0 +1,203 @@ +"""Automatic luminosity-function registries.""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from dataclasses import dataclass + +from lfkit.luminosity_functions import conditional_models +from lfkit.luminosity_functions._discovery import iter_model_functions + + +@dataclass(frozen=True) +class LFModel: + """Description of a registered luminosity-function model.""" + + name: str + function: Callable + independent_variable: str = "absolute_mag" + requires_z: bool = False + + +def discover_lf_models() -> tuple[ + dict[str, LFModel], + dict[str, LFModel], + dict[str, Callable], +]: + """Discover LF models, conditional LF models, and apparent-magnitude evaluators.""" + lf_models: dict[str, LFModel] = {} + conditional_lf_models: dict[str, LFModel] = {} + from_m_models: dict[str, Callable] = {} + + _discover_models_package(lf_models, from_m_models) + _discover_conditional_models(conditional_lf_models) + + return lf_models, conditional_lf_models, from_m_models + + +def _discover_models_package( + lf_models: dict[str, LFModel], + from_m_models: dict[str, Callable], +) -> None: + """Discover LF models from ``luminosity_functions.models``.""" + for name, obj in iter_model_functions().items(): + _register_lf_model( + name, + obj, + lf_models=lf_models, + from_m_models=from_m_models, + name_transform=_public_model_name, + ) + + +def _discover_conditional_models( + conditional_lf_models: dict[str, LFModel], +) -> None: + """Discover conditional LF models.""" + _register_module_lf_models( + conditional_models, + lf_models=conditional_lf_models, + from_m_models=None, + name_transform=_public_model_name, + ) + + +def _register_lf_model( + name: str, + obj: Callable, + *, + lf_models: dict[str, LFModel], + from_m_models: dict[str, Callable] | None, + name_transform: Callable[[str], str] | None, +) -> None: + """Register one public LF function.""" + if not callable(obj): + return + + sig = inspect.signature(obj) + params = list(sig.parameters) + + if not params: + return + + first_arg = params[0] + + if name.endswith("_from_m"): + if from_m_models is not None: + from_m_models[name.removesuffix("_from_m")] = obj + return + + if first_arg not in {"absolute_mag", "magnitude", "luminosity"}: + return + + public_name = name_transform(name) if name_transform is not None else name + + lf_models[public_name] = LFModel( + name=public_name, + function=obj, + independent_variable=first_arg, + requires_z=_requires_second_independent_variable(sig), + ) + + +def _register_module_lf_models( + module: object, + *, + lf_models: dict[str, LFModel], + from_m_models: dict[str, Callable] | None, + name_transform: Callable[[str], str] | None, +) -> None: + """Register public LF functions from one module.""" + for name in getattr(module, "__all__", []): + _register_lf_model( + name, + getattr(module, name), + lf_models=lf_models, + from_m_models=from_m_models, + name_transform=name_transform, + ) + + +def _public_model_name(name: str) -> str: + """Return the public model name for LF functions.""" + public_name = name + + if public_name.startswith("conditional_"): + public_name = public_name.removeprefix("conditional_") + + if public_name.endswith("_lf"): + public_name = public_name.removesuffix("_lf") + + return public_name + + +def _requires_second_independent_variable( + signature: inspect.Signature, +) -> bool: + """Return whether model needs a second positional independent variable.""" + params = list(signature.parameters.values()) + + if len(params) < 2: + return False + + second = params[1] + + return second.kind in { + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + } and second.name in {"z", "redshift", "condition", "x"} + + +LF_MODELS, CONDITIONAL_LF_MODELS, LF_FROM_M_MODELS = discover_lf_models() + + +def available_lf_models() -> tuple[str, ...]: + """Return available luminosity-function model names.""" + return tuple(sorted(LF_MODELS)) + + +def available_conditional_lf_models() -> tuple[str, ...]: + """Return available conditional luminosity-function model names.""" + return tuple(sorted(CONDITIONAL_LF_MODELS)) + + +def available_lf_from_m_models() -> tuple[str, ...]: + """Return LF models with apparent-magnitude evaluators.""" + return tuple(sorted(LF_FROM_M_MODELS)) + + +def get_lf_model(name: str) -> LFModel: + """Return a registered luminosity-function model.""" + try: + return LF_MODELS[name] + except KeyError as exc: + available = ", ".join(available_lf_models()) + raise ValueError( + f"Unknown luminosity-function model {name!r}. " + f"Available models: {available}." + ) from exc + + +def get_conditional_lf_model(name: str) -> LFModel: + """Return a registered conditional luminosity-function model.""" + try: + return CONDITIONAL_LF_MODELS[name] + except KeyError as exc: + available = ", ".join(available_conditional_lf_models()) + raise ValueError( + f"Unknown conditional luminosity-function model {name!r}. " + f"Available conditional models: {available}." + ) from exc + + +def get_lf_from_m_model(name: str) -> Callable: + """Return an apparent-magnitude LF evaluator.""" + try: + return LF_FROM_M_MODELS[name] + except KeyError as exc: + available = ", ".join(available_lf_from_m_models()) + raise ValueError( + f"phi_from_m is not defined for luminosity-function model {name!r}. " + f"Available models: {available}." + ) from exc diff --git a/src/lfkit/photometry/conditional_lf_models.py b/src/lfkit/photometry/conditional_lf_models.py deleted file mode 100644 index ccbb8b45..00000000 --- a/src/lfkit/photometry/conditional_lf_models.py +++ /dev/null @@ -1,424 +0,0 @@ -"""Conditional luminosity function model utilities. - -This module provides conditional wrappers around existing LFKit luminosity -function models. - -A conditional luminosity function has the form ``Phi(M | x)``, where ``M`` is -absolute magnitude and ``x`` is an external conditioning variable. The -conditioning variable is intentionally generic. It may represent redshift, -halo mass, environment, galaxy type, richness, stellar mass, or any other -quantity. - -This module does not implement HOD or halo-model machinery. -""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import cast - -import numpy as np - -from lfkit.photometry.lf_parameter_models import evaluate_lf_parameters -from lfkit.photometry.luminosities import ( - luminosity_ratio, - magnitude_difference_from_luminosity_ratio, -) -from lfkit.photometry.luminosity_function import schechter, double_schechter -from lfkit.utils.types import ( - ConditionalParameter, - FloatArray, - FloatInput, - ParameterValue, -) -from lfkit.utils.validators import validate_array - - -__all__ = [ - "conditional_schechter", - "conditional_evolving_schechter", - "conditional_double_schechter", - "lognormal_conditional_lf", - "modified_schechter_conditional_lf", - "two_component_conditional_lf", -] - - -def conditional_schechter( - absolute_mag: FloatInput, - condition: FloatInput, - *, - phi_star: ConditionalParameter, - m_star: ConditionalParameter, - alpha: ConditionalParameter, -) -> FloatArray: - """Evaluate a conditional Schechter luminosity function. - - Args: - absolute_mag: Absolute magnitude value(s). - condition: Values of the conditioning variable. - phi_star: Schechter normalization. May be scalar, array-like, or - callable of ``condition``. - m_star: Characteristic absolute magnitude. May be scalar, array-like, - or callable of ``condition``. - alpha: Faint-end slope. May be scalar, array-like, or callable of - ``condition``. - - Returns: - Conditional Schechter luminosity function values. - """ - condition_arr = validate_array(condition, name="condition") - - return schechter( - absolute_mag, - phi_star=_evaluate_conditional_parameter( - phi_star, - condition_arr, - name="phi_star", - ), - m_star=_evaluate_conditional_parameter( - m_star, - condition_arr, - name="m_star", - ), - alpha=_evaluate_conditional_parameter( - alpha, - condition_arr, - name="alpha", - ), - ) - - -def conditional_evolving_schechter( - absolute_mag: FloatInput, - condition: FloatInput, - *, - phi_model: str = "linear_p", - phi_kwargs: Mapping[str, ParameterValue] | None = None, - m_star_model: str = "linear_q", - m_star_kwargs: Mapping[str, ParameterValue] | None = None, - alpha_model: str = "constant", - alpha_kwargs: Mapping[str, ParameterValue] | None = None, -) -> FloatArray: - """Evaluate a conditional Schechter LF using LFKit parameter models. - - This is the conditional LF analogue of ``evolving_schechter``. The - conditioning variable is passed to LFKit's registered parameter models. - - Args: - absolute_mag: Absolute magnitude value(s). - condition: Values of the conditioning variable. - phi_model: Evolution/condition model for ``phi_star``. - phi_kwargs: Keyword arguments passed to the selected ``phi_star`` model. - m_star_model: Evolution/condition model for ``M_star``. - m_star_kwargs: Keyword arguments passed to the selected ``M_star`` model. - alpha_model: Evolution/condition model for ``alpha``. - alpha_kwargs: Keyword arguments passed to the selected ``alpha`` model. - - Returns: - Conditional Schechter luminosity function values. - - Raises: - ValueError: If an unsupported parameter model is requested. - """ - condition_arr = validate_array(condition, name="condition") - - phi_star, m_star, alpha = evaluate_lf_parameters( - condition_arr, - phi_model=phi_model, - phi_kwargs=phi_kwargs, - m_star_model=m_star_model, - m_star_kwargs=m_star_kwargs, - alpha_model=alpha_model, - alpha_kwargs=alpha_kwargs, - ) - - return schechter( - absolute_mag, - phi_star=phi_star, - m_star=m_star, - alpha=alpha, - ) - - -def conditional_double_schechter( - absolute_mag: FloatInput, - condition: FloatInput, - *, - phi_star: ConditionalParameter, - m_star: ConditionalParameter, - alpha: float, - beta: float, - m_transition: ConditionalParameter, -) -> FloatArray: - """Evaluate a conditional double-power-law Schechter luminosity function. - - Args: - absolute_mag: Absolute magnitude value(s). - condition: Values of the conditioning variable. - phi_star: Overall normalization. May be scalar, array-like, or - callable of ``condition``. - m_star: Characteristic absolute magnitude. May be scalar, array-like, - or callable of ``condition``. - alpha: Bright/intermediate faint-end slope parameter. - beta: Additional faint-end slope modifier. - m_transition: Transition magnitude. May be scalar, array-like, or - callable of ``condition``. - - Returns: - Conditional double-power-law Schechter luminosity function values. - """ - condition_arr = validate_array(condition, name="condition") - - return double_schechter( - absolute_mag, - phi_star=_evaluate_conditional_parameter( - phi_star, - condition_arr, - name="phi_star", - ), - m_star=_evaluate_conditional_parameter( - m_star, - condition_arr, - name="m_star", - ), - alpha=alpha, - beta=beta, - m_transition=_evaluate_conditional_parameter( - m_transition, - condition_arr, - name="m_transition", - ), - ) - - -def lognormal_conditional_lf( - absolute_mag: FloatInput, - condition: FloatInput, - *, - mean_absolute_mag: ConditionalParameter, - sigma_log_luminosity: ConditionalParameter, - amplitude: ConditionalParameter = 1.0, -) -> FloatArray: - """Evaluate a lognormal conditional luminosity function in magnitudes. - - Args: - absolute_mag: Absolute magnitude value(s). - condition: Values of the conditioning variable. - mean_absolute_mag: Mean absolute magnitude. - May be scalar, array-like, or callable of ``condition``. - sigma_log_luminosity: Scatter in ``log10(L)`` at fixed condition. - May be scalar, array-like, or callable of ``condition``. - amplitude: Non-negative amplitude of the component. - May be scalar, array-like, or callable of ``condition``. - - Returns: - Lognormal conditional luminosity function values. - """ - absolute_mag_arr = validate_array(absolute_mag, name="absolute_mag") - condition_arr = validate_array(condition, name="condition") - - mean_absolute_mag_arr = _evaluate_conditional_parameter( - mean_absolute_mag, - condition_arr, - name="mean_absolute_mag", - ) - sigma_log_luminosity_arr = _evaluate_conditional_parameter( - sigma_log_luminosity, - condition_arr, - name="sigma_log_luminosity", - ) - amplitude_arr = _evaluate_conditional_parameter( - amplitude, - condition_arr, - name="amplitude", - ) - - if np.any(sigma_log_luminosity_arr <= 0.0): - raise ValueError("sigma_log_luminosity must be positive.") - - if np.any(amplitude_arr < 0.0): - raise ValueError("amplitude must be non-negative.") - - delta_log_luminosity = -0.4 * (absolute_mag_arr - mean_absolute_mag_arr) - - phi = ( - amplitude_arr - * 0.4 - / (np.sqrt(2.0 * np.pi) * sigma_log_luminosity_arr) - * np.exp(-0.5 * (delta_log_luminosity / sigma_log_luminosity_arr) ** 2.0) - ) - - return _validate_lf_output(phi, name="lognormal_conditional_lf") - - -def modified_schechter_conditional_lf( - absolute_mag: FloatInput, - condition: FloatInput, - *, - phi_star: ConditionalParameter, - m_star: ConditionalParameter, - alpha: ConditionalParameter, -) -> FloatArray: - """Evaluate a modified Schechter conditional luminosity function. - - This uses a squared exponential cutoff in luminosity ratio instead of the - standard Schechter exponential cutoff. - - Args: - absolute_mag: Absolute magnitude value(s). - condition: Values of the conditioning variable. - phi_star: Component normalization. - m_star: Characteristic absolute magnitude. - alpha: Faint-end slope. - - Returns: - Modified Schechter conditional luminosity function values. - """ - absolute_mag_arr = validate_array(absolute_mag, name="absolute_mag") - condition_arr = validate_array(condition, name="condition") - - phi_star_arr = _evaluate_conditional_parameter( - phi_star, - condition_arr, - name="phi_star", - ) - m_star_arr = _evaluate_conditional_parameter( - m_star, - condition_arr, - name="m_star", - ) - alpha_arr = _evaluate_conditional_parameter( - alpha, - condition_arr, - name="alpha", - ) - - if np.any(phi_star_arr < 0.0): - raise ValueError("phi_star must be non-negative.") - - x = luminosity_ratio(absolute_mag_arr, m_star_arr) - - phi = 0.4 * np.log(10.0) * phi_star_arr * x ** (alpha_arr + 1.0) * np.exp(-(x**2.0)) - - return _validate_lf_output( - phi, - name="modified_schechter_conditional_lf", - ) - - -def two_component_conditional_lf( - absolute_mag: FloatInput, - condition: FloatInput, - *, - lognormal_mean_absolute_mag: ConditionalParameter, - lognormal_sigma_log_luminosity: ConditionalParameter, - modified_phi_star: ConditionalParameter, - modified_alpha: ConditionalParameter, - lognormal_amplitude: ConditionalParameter = 1.0, - modified_m_star: ConditionalParameter | None = None, - modified_luminosity_fraction: ConditionalParameter = 0.562, -) -> FloatArray: - """Evaluate the sum of lognormal and modified Schechter components. - - Args: - absolute_mag: Absolute magnitude value(s). - condition: Values of the conditioning variable. - lognormal_mean_absolute_mag: Mean absolute magnitude of the lognormal - component. May be scalar, array-like, or callable of ``condition``. - lognormal_sigma_log_luminosity: Scatter in ``log10(L)`` for the - lognormal component. May be scalar, array-like, or callable of - ``condition``. - modified_phi_star: Normalization of the modified Schechter component. - May be scalar, array-like, or callable of ``condition``. - modified_alpha: Faint-end slope of the modified Schechter component. - May be scalar, array-like, or callable of ``condition``. - lognormal_amplitude: Non-negative amplitude of the lognormal component. - May be scalar, array-like, or callable of ``condition``. - modified_m_star: Characteristic absolute magnitude of the modified - Schechter component. If omitted, it is derived from - ``lognormal_mean_absolute_mag`` and ``modified_luminosity_fraction``. - modified_luminosity_fraction: Ratio used to derive the modified - Schechter characteristic luminosity from the lognormal mean - luminosity when ``modified_m_star`` is omitted. - - Returns: - Combined conditional luminosity function values. - """ - condition_arr = validate_array(condition, name="condition") - - lognormal_mean_absolute_mag_arr = _evaluate_conditional_parameter( - lognormal_mean_absolute_mag, - condition_arr, - name="lognormal_mean_absolute_mag", - ) - - lognormal_phi = lognormal_conditional_lf( - absolute_mag=absolute_mag, - condition=condition_arr, - mean_absolute_mag=lognormal_mean_absolute_mag_arr, - sigma_log_luminosity=lognormal_sigma_log_luminosity, - amplitude=lognormal_amplitude, - ) - - if modified_m_star is None: - modified_luminosity_fraction_arr = _evaluate_conditional_parameter( - modified_luminosity_fraction, - condition_arr, - name="modified_luminosity_fraction", - ) - - if np.any(modified_luminosity_fraction_arr <= 0.0): - raise ValueError("modified_luminosity_fraction must be positive.") - - modified_m_star_arr = lognormal_mean_absolute_mag_arr + ( - magnitude_difference_from_luminosity_ratio(modified_luminosity_fraction_arr) - ) - else: - modified_m_star_arr = _evaluate_conditional_parameter( - modified_m_star, - condition_arr, - name="modified_m_star", - ) - - modified_phi = modified_schechter_conditional_lf( - absolute_mag=absolute_mag, - condition=condition_arr, - phi_star=modified_phi_star, - m_star=modified_m_star_arr, - alpha=modified_alpha, - ) - - return _validate_lf_output( - lognormal_phi + modified_phi, - name="two_component_conditional_lf", - ) - - -def _evaluate_conditional_parameter( - parameter: ConditionalParameter, - condition: FloatArray, - *, - name: str, -) -> FloatArray: - """Evaluate a scalar, array-like, or callable conditional parameter.""" - if callable(parameter): - values = parameter(condition) - else: - values = cast(ParameterValue, parameter) - - return validate_array(values, name=name) - - -def _validate_lf_output( - phi: FloatInput, - *, - name: str, -) -> FloatArray: - """Validate luminosity function model output.""" - phi_arr = validate_array(phi, name=name) - - if np.any(phi_arr < 0.0): - raise ValueError(f"{name} returned negative values, which are not allowed.") - - return np.asarray(phi_arr, dtype=np.float64) diff --git a/src/lfkit/photometry/luminosities.py b/src/lfkit/photometry/luminosities.py index 30dd495a..d54decd2 100644 --- a/src/lfkit/photometry/luminosities.py +++ b/src/lfkit/photometry/luminosities.py @@ -19,8 +19,6 @@ from __future__ import annotations import numpy as np -from numpy.random import Generator, default_rng -from scipy.special import gamma, gammaincc from lfkit.utils.types import FloatArray, FloatInput from lfkit.utils.validators import validate_array @@ -31,13 +29,16 @@ "magnitude_difference_from_luminosity_ratio", "luminosity_weight_from_magnitude", "luminosity_from_magnitude", - "schechter_cumulative_number_density_luminosity", - "schechter_luminosity_density", - "schechter_mean_luminosity", - "sample_schechter_luminosity", - "schechter_selection_function", ) +__api_aliases__ = { + "luminosity_ratio": "ratio", + "luminosity_ratio_from_magnitudes": "ratio_from_magnitudes", + "magnitude_difference_from_luminosity_ratio": "magnitude_difference_from_ratio", + "luminosity_weight_from_magnitude": "weight_from_magnitude", + "luminosity_from_magnitude": "from_magnitude", +} + def luminosity_ratio( absolute_mag: FloatInput, @@ -180,247 +181,3 @@ def luminosity_from_magnitude( reference_magnitude, ) return np.asarray(reference_luminosity * ratio, dtype=float) - - -def schechter_cumulative_number_density_luminosity( - luminosity_min: FloatInput, - *, - phi_star: float, - l_star: float, - alpha: float, -) -> FloatArray: - r"""Return cumulative Schechter number density above a luminosity threshold. - - This evaluates - - .. math:: - - n(L > L_{\min}) = \phi_* \, \Gamma(\alpha + 1, L_{\min} / L_*), - - where ``Gamma`` is the upper incomplete gamma function. - - Args: - luminosity_min: Lower luminosity threshold(s). - phi_star: Schechter normalization. - l_star: Characteristic luminosity ``L_star``. - alpha: Faint-end slope. - - Returns: - NumPy array of cumulative number densities above ``luminosity_min``. - - Raises: - ValueError: If ``phi_star`` is negative, ``l_star`` is not strictly - positive, or ``alpha <= -1``. - """ - l_min = validate_array(luminosity_min, name="luminosity_min") - - if np.any(l_min < 0): - raise ValueError("luminosity_min must be non-negative.") - - if phi_star < 0: - raise ValueError("phi_star must be non-negative.") - - if l_star <= 0: - raise ValueError("l_star must be strictly positive.") - - s = alpha + 1.0 - if s <= 0: - raise ValueError( - "Cumulative number density is undefined for alpha <= -1 " - "because the integral diverges." - ) - - x_min = np.clip(l_min / l_star, 0.0, 1e300) - n_gt = phi_star * gamma(s) * gammaincc(s, x_min) - return np.asarray(n_gt, dtype=float) - - -def schechter_luminosity_density( - *, - phi_star: float, - l_star: float, - alpha: float, -) -> float: - r"""Return total luminosity density for a Schechter luminosity function. - - This evaluates - - .. math:: - - \rho_L = \phi_* \, L_* \, \Gamma(\alpha + 2). - - Args: - phi_star: Schechter normalization. - l_star: Characteristic luminosity ``L_star``. - alpha: Faint-end slope. - - Returns: - Total luminosity density. - - Raises: - ValueError: If ``phi_star`` is negative, ``l_star`` is not strictly - positive, or ``alpha <= -2``. - """ - if phi_star < 0: - raise ValueError("phi_star must be non-negative.") - - if l_star <= 0: - raise ValueError("l_star must be strictly positive.") - - s = alpha + 2.0 - if s <= 0: - raise ValueError( - "Luminosity density is undefined for alpha <= -2 " - "because the integral diverges." - ) - - return float(phi_star * l_star * gamma(s)) - - -def schechter_mean_luminosity( - *, - l_star: float, - alpha: float, -) -> float: - r"""Return the mean luminosity of a normalized Schechter distribution. - - This evaluates - - .. math:: - - \langle L \rangle = L_* \, \Gamma(\alpha + 2) / \Gamma(\alpha + 1). - - For finite values this simplifies to - - .. math:: - - \langle L \rangle = L_* (\alpha + 1), - - provided ``alpha > -1``. - - Args: - l_star: Characteristic luminosity ``L_star``. - alpha: Faint-end slope. - - Returns: - Mean luminosity. - - Raises: - ValueError: If ``l_star`` is not strictly positive or ``alpha <= -1``. - """ - if l_star <= 0: - raise ValueError("l_star must be strictly positive.") - - s = alpha + 1.0 - if s <= 0: - raise ValueError( - "Mean luminosity is undefined for alpha <= -1 " - "because the number-density integral diverges." - ) - - return float(l_star * gamma(alpha + 2.0) / gamma(alpha + 1.0)) - - -def sample_schechter_luminosity( - size: int | tuple[int, ...], - *, - l_star: float, - alpha: float, - rng: Generator | None = None, -) -> FloatArray: - r"""Sample luminosities from a normalized Schechter distribution. - - This samples from - - .. math:: - - p(L) \propto (L / L_*)^{\alpha} \exp(-L / L_*), - - which is equivalent to a Gamma distribution in - - .. math:: - - x = L / L_*, - - with shape parameter - - .. math:: - - k = \alpha + 1. - - Args: - size: Number or shape of samples to draw. - l_star: Characteristic luminosity ``L_star``. - alpha: Faint-end slope. - rng: Optional NumPy random number generator. - - Returns: - NumPy array of sampled luminosities. - - Raises: - ValueError: If ``l_star`` is not strictly positive or ``alpha <= -1``. - """ - if l_star <= 0: - raise ValueError("l_star must be strictly positive.") - - shape = alpha + 1.0 - if shape <= 0: - raise ValueError( - "Sampling from the normalized Schechter distribution requires alpha > -1." - ) - - generator = default_rng() if rng is None else rng - samples = generator.gamma(shape=shape, scale=l_star, size=size) - return np.asarray(samples, dtype=float) - - -def schechter_selection_function( - luminosity_min: FloatInput, - *, - l_star: float, - alpha: float, -) -> FloatArray: - r"""Return the Schechter selection fraction above a luminosity threshold. - - This evaluates - - .. math:: - - S(L_{\min}) = n(L > L_{\min}) / n_{\mathrm{tot}} - = \Gamma(\alpha + 1, L_{\min} / L_*) / \Gamma(\alpha + 1), - - which is equivalently - - .. math:: - - S(L_{\min}) = \mathrm{gammaincc}(\alpha + 1, L_{\min} / L_*). - - Args: - luminosity_min: Lower luminosity threshold(s). - l_star: Characteristic luminosity ``L_star``. - alpha: Faint-end slope. - - Returns: - NumPy array of selection fractions between 0 and 1. - - Raises: - ValueError: If ``l_star`` is not strictly positive, any luminosity - threshold is negative, or ``alpha <= -1``. - """ - l_min = validate_array(luminosity_min, name="luminosity_min") - - if np.any(l_min < 0): - raise ValueError("luminosity_min must be non-negative.") - - if l_star <= 0: - raise ValueError("l_star must be strictly positive.") - - s = alpha + 1.0 - if s <= 0: - raise ValueError( - "Selection function is undefined for alpha <= -1 " - "because the total number-density integral diverges." - ) - - x_min = np.clip(l_min / l_star, 0.0, 1e300) - return np.asarray(gammaincc(s, x_min), dtype=float) diff --git a/src/lfkit/photometry/magnitudes.py b/src/lfkit/photometry/magnitudes.py index 7e46d2ad..96b3d7fb 100644 --- a/src/lfkit/photometry/magnitudes.py +++ b/src/lfkit/photometry/magnitudes.py @@ -30,6 +30,14 @@ "apparent_magnitude_from_luminosity_distance", ] +__api_aliases__ = { + "total_magnitude_correction": "correction", + "absolute_magnitude": "absolute", + "absolute_magnitude_from_luminosity_distance": "absolute_from_luminosity_distance", + "apparent_magnitude": "apparent", + "apparent_magnitude_from_luminosity_distance": "apparent_from_luminosity_distance", +} + def total_magnitude_correction( *, diff --git a/tests/benchmarks/test_cacciato_clf_reference.py b/tests/benchmarks/test_cacciato_clf_reference.py index 1d4fae89..20147720 100644 --- a/tests/benchmarks/test_cacciato_clf_reference.py +++ b/tests/benchmarks/test_cacciato_clf_reference.py @@ -11,17 +11,32 @@ For more information, see https://arxiv.org/abs/1207.0503. """ +from __future__ import annotations + +from functools import partial + import numpy as np import pytest -from lfkit.photometry.conditional_lf_models import ( - lognormal_conditional_lf, - modified_schechter_conditional_lf, - two_component_conditional_lf, -) +from lfkit.luminosity_functions.conditional_models import conditionalize_lf_model +from lfkit.luminosity_functions.models.modifiers import apply_luminosity_cutoff +from lfkit.luminosity_functions.registry import get_conditional_lf_model, get_lf_model from lfkit.photometry.luminosities import magnitude_difference_from_luminosity_ratio +conditional_lognormal_lf = get_conditional_lf_model("lognormal").function +conditional_two_component_lf = get_conditional_lf_model("two_component").function + +modified_schechter = partial( + apply_luminosity_cutoff, + base_lf=get_lf_model("schechter").function, + cutoff_power=2.0, + cutoff_amplitude=1.0, +) +modified_schechter.__name__ = "modified_schechter" + +conditional_modified_schechter = conditionalize_lf_model(modified_schechter) + pytestmark = pytest.mark.benchmark @@ -106,7 +121,7 @@ def test_cacciato_central_lognormal_matches_reference_formula() -> None: gamma2=0.245, ) - result = lognormal_conditional_lf( + result = conditional_lognormal_lf( absolute_mag=absolute_mag, condition=log_halo_mass, mean_absolute_mag=mean_absolute_mag, @@ -146,7 +161,7 @@ def test_cacciato_satellite_modified_schechter_matches_reference_formula() -> No b2=-0.217, ) - result = modified_schechter_conditional_lf( + result = conditional_modified_schechter( absolute_mag=absolute_mag, condition=log_halo_mass, phi_star=phi_star, @@ -184,7 +199,7 @@ def test_cacciato_central_satellite_matches_sum_of_components() -> None: b2=-0.217, ) - result = two_component_conditional_lf( + result = conditional_two_component_lf( absolute_mag=absolute_mag, condition=log_halo_mass, lognormal_mean_absolute_mag=central_mag, diff --git a/tests/benchmarks/test_cacciato_hod_reference.py b/tests/benchmarks/test_cacciato_hod_reference.py index d2fbce85..f8b92a89 100644 --- a/tests/benchmarks/test_cacciato_hod_reference.py +++ b/tests/benchmarks/test_cacciato_hod_reference.py @@ -11,17 +11,29 @@ from __future__ import annotations +from functools import partial + import numpy as np import pytest -from lfkit.photometry.conditional_lf_models import ( - lognormal_conditional_lf, - modified_schechter_conditional_lf, - two_component_conditional_lf, -) -from lfkit.photometry.luminosities import ( - magnitude_difference_from_luminosity_ratio, +from lfkit.luminosity_functions.conditional_models import conditionalize_lf_model +from lfkit.luminosity_functions.models.modifiers import apply_luminosity_cutoff +from lfkit.luminosity_functions.registry import get_conditional_lf_model, get_lf_model +from lfkit.photometry.luminosities import magnitude_difference_from_luminosity_ratio + + +conditional_lognormal_lf = get_conditional_lf_model("lognormal").function +conditional_two_component_lf = get_conditional_lf_model("two_component").function + +modified_schechter = partial( + apply_luminosity_cutoff, + base_lf=get_lf_model("schechter").function, + cutoff_power=2.0, + cutoff_amplitude=1.0, ) +modified_schechter.__name__ = "modified_schechter" + +conditional_modified_schechter = conditionalize_lf_model(modified_schechter) pytestmark = pytest.mark.benchmark @@ -147,7 +159,7 @@ def test_lfkit_cacciato_central_occupation_matches_luminosity_reference() -> Non central_mag = magnitude_from_luminosity_ratio(cacciato_lc(halo_mass)) - lfkit_clf = lognormal_conditional_lf( + lfkit_clf = conditional_lognormal_lf( absolute_mag=magnitude_grid.reshape(1, -1), condition=np.log10(halo_mass).reshape(-1, 1), mean_absolute_mag=central_mag.reshape(-1, 1), @@ -183,7 +195,7 @@ def test_lfkit_cacciato_satellite_occupation_matches_luminosity_reference() -> N satellite_m_star = central_mag + magnitude_difference_from_luminosity_ratio(0.562) phi_star = cacciato_phi_star_satellite(halo_mass) - lfkit_clf = modified_schechter_conditional_lf( + lfkit_clf = conditional_modified_schechter( absolute_mag=magnitude_grid.reshape(1, -1), condition=np.log10(halo_mass).reshape(-1, 1), phi_star=phi_star.reshape(-1, 1), @@ -226,7 +238,7 @@ def test_lfkit_cacciato_total_occupation_matches_luminosity_reference() -> None: central_mag = magnitude_from_luminosity_ratio(cacciato_lc(halo_mass)) phi_star = cacciato_phi_star_satellite(halo_mass) - lfkit_clf = two_component_conditional_lf( + lfkit_clf = conditional_two_component_lf( absolute_mag=magnitude_grid.reshape(1, -1), condition=np.log10(halo_mass).reshape(-1, 1), lognormal_mean_absolute_mag=central_mag.reshape(-1, 1), diff --git a/tests/test_api_conditional_luminosity_function.py b/tests/test_api_conditional_luminosity_function.py index 46b853a1..9a3b0ce9 100644 --- a/tests/test_api_conditional_luminosity_function.py +++ b/tests/test_api_conditional_luminosity_function.py @@ -21,7 +21,7 @@ def test_conditional_schechter_constructor_delegates_to_luminosity_function(): ) assert isinstance(lf, LuminosityFunction) - assert lf.model == "conditional_schechter" + assert lf.model == "schechter" assert lf.parameters_dict == { "phi_star": 1.0e-3, "m_star": -20.5, @@ -30,46 +30,6 @@ def test_conditional_schechter_constructor_delegates_to_luminosity_function(): assert lf.meta == {"source": "test"} -def test_conditional_evolving_schechter_constructor_delegates_to_luminosity_function(): - lf = ConditionalLuminosityFunction.evolving_schechter( - phi_model="linear_p", - phi_kwargs={"phi_star0": 1.0e-3, "p": 0.2}, - m_star_model="linear_q", - m_star_kwargs={"m_star0": -20.5, "q": 0.5}, - alpha_model="constant", - alpha_kwargs={"value": -1.1}, - meta={"source": "test"}, - ) - - assert isinstance(lf, LuminosityFunction) - assert lf.model == "conditional_evolving_schechter" - assert lf.parameters_dict == { - "phi_model": "linear_p", - "phi_kwargs": {"phi_star0": 1.0e-3, "p": 0.2}, - "m_star_model": "linear_q", - "m_star_kwargs": {"m_star0": -20.5, "q": 0.5}, - "alpha_model": "constant", - "alpha_kwargs": {"value": -1.1}, - } - assert lf.meta == {"source": "test"} - - -def test_conditional_evolving_schechter_constructor_uses_empty_default_kwargs(): - lf = ConditionalLuminosityFunction.evolving_schechter() - - assert isinstance(lf, LuminosityFunction) - assert lf.model == "conditional_evolving_schechter" - assert lf.parameters_dict == { - "phi_model": "linear_p", - "phi_kwargs": {}, - "m_star_model": "linear_q", - "m_star_kwargs": {}, - "alpha_model": "constant", - "alpha_kwargs": {}, - } - assert lf.meta == {} - - def test_conditional_double_schechter_constructor_delegates_to_luminosity_function(): lf = ConditionalLuminosityFunction.double_schechter( phi_star=1.0e-3, @@ -81,7 +41,7 @@ def test_conditional_double_schechter_constructor_delegates_to_luminosity_functi ) assert isinstance(lf, LuminosityFunction) - assert lf.model == "conditional_double_schechter" + assert lf.model == "double_schechter" assert lf.parameters_dict == { "phi_star": 1.0e-3, "m_star": -20.5, @@ -101,7 +61,7 @@ def test_lognormal_constructor_delegates_to_luminosity_function(): ) assert isinstance(lf, LuminosityFunction) - assert lf.model == "lognormal_conditional_lf" + assert lf.model == "lognormal" assert lf.parameters_dict == { "mean_absolute_mag": -20.5, "sigma_log_luminosity": 0.2, @@ -117,7 +77,7 @@ def test_lognormal_constructor_uses_default_amplitude(): ) assert isinstance(lf, LuminosityFunction) - assert lf.model == "lognormal_conditional_lf" + assert lf.model == "lognormal" assert lf.parameters_dict == { "mean_absolute_mag": -20.5, "sigma_log_luminosity": 0.2, @@ -126,22 +86,10 @@ def test_lognormal_constructor_uses_default_amplitude(): assert lf.meta == {} -def test_modified_schechter_constructor_delegates_to_luminosity_function(): - lf = ConditionalLuminosityFunction.modified_schechter( - phi_star=1.0e-3, - m_star=-20.5, - alpha=-1.1, - meta={"source": "test"}, - ) +def test_modified_schechter_constructor_is_not_registered() -> None: + """Tests modified Schechter is not exposed as a standalone conditional model.""" - assert isinstance(lf, LuminosityFunction) - assert lf.model == "modified_schechter_conditional_lf" - assert lf.parameters_dict == { - "phi_star": 1.0e-3, - "m_star": -20.5, - "alpha": -1.1, - } - assert lf.meta == {"source": "test"} + assert not hasattr(ConditionalLuminosityFunction, "modified_schechter") def test_two_component_constructor_delegates_to_luminosity_function(): @@ -157,7 +105,7 @@ def test_two_component_constructor_delegates_to_luminosity_function(): ) assert isinstance(lf, LuminosityFunction) - assert lf.model == "two_component_conditional_lf" + assert lf.model == "two_component" assert lf.parameters_dict == { "lognormal_mean_absolute_mag": -21.0, "lognormal_sigma_log_luminosity": 0.2, @@ -179,7 +127,7 @@ def test_two_component_constructor_uses_default_optional_parameters(): ) assert isinstance(lf, LuminosityFunction) - assert lf.model == "two_component_conditional_lf" + assert lf.model == "two_component" assert lf.parameters_dict == { "lognormal_mean_absolute_mag": -21.0, "lognormal_sigma_log_luminosity": 0.2, diff --git a/tests/test_api_luminosity_function.py b/tests/test_api_luminosity_function.py index d5d02353..6b82ca9d 100644 --- a/tests/test_api_luminosity_function.py +++ b/tests/test_api_luminosity_function.py @@ -7,13 +7,16 @@ from __future__ import annotations -import numpy as np +import inspect import pytest +import numpy as np + import pyccl as ccl -from lfkit.api._expose import expose_lf_function +from lfkit.api._namespaces import expose_lf_function from lfkit.api.luminosity_function import LuminosityFunction +from lfkit.luminosity_functions.registry import LF_MODELS def make_test_cosmology(): @@ -293,7 +296,7 @@ def test_unsupported_model_raises_clear_error(): parameters={}, ) - with pytest.raises(ValueError, match="Unsupported luminosity function model"): + with pytest.raises(ValueError, match="Unknown luminosity-function model"): lf.phi(-20.0, 0.5) @@ -404,18 +407,92 @@ def test_luminosities_namespace_exposes_expected_methods(): alpha=-1.1, ) - expected = [ + expected_methods = [ "ratio", "ratio_from_magnitudes", "magnitude_difference_from_ratio", "weight_from_magnitude", "from_magnitude", - "schechter_cumulative_number_density", - "schechter_luminosity_density", - "schechter_mean_luminosity", - "sample_schechter", - "schechter_selection", ] - for name in expected: + for name in expected_methods: assert callable(getattr(lf.luminosities, name)) + + +def test_integrals_namespace_exposes_expected_methods(): + lf = LuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + ) + + expected_methods = [ + "number_density", + "weighted", + "selection_weighted_number_density", + "luminosity_density", + "mean_luminosity", + "cumulative_number_density", + "magnitude_window_number_density", + "selection_fraction", + "selection_function", + "luminosity_weight", + ] + + for name in expected_methods: + assert callable(getattr(lf.integrals, name)) + + +def _minimal_parameter_payload(function): + payload = {} + + for name, parameter in inspect.signature(function).parameters.items(): + if name in {"absolute_mag", "z"}: + continue + + if parameter.default is not inspect.Parameter.empty: + continue + + if "phi" in name: + payload[name] = 1.0e-3 + elif "m_star" in name or "mag" in name: + payload[name] = -20.5 + elif name in {"alpha", "beta"}: + payload[name] = -1.1 + elif "sigma" in name: + payload[name] = 0.2 + elif "amplitude" in name: + payload[name] = 1.0 + elif "fraction" in name: + payload[name] = 0.5 + elif name == "m_transition": + payload[name] = -19.5 + else: + pytest.skip(f"No test default for required parameter {name!r}") + + return payload + + +@pytest.mark.parametrize("model_name", sorted(LF_MODELS)) +def test_registered_luminosity_function_models_expose_generic_integrals(model_name): + model_spec = LF_MODELS[model_name] + + lf = getattr(LuminosityFunction, model_name)( + **_minimal_parameter_payload(model_spec.function) + ) + + expected_methods = [ + "number_density", + "weighted", + "selection_weighted_number_density", + "luminosity_density", + "mean_luminosity", + "cumulative_number_density", + "magnitude_window_number_density", + "selection_fraction", + "selection_function", + "luminosity_weight", + ] + + for name in expected_methods: + assert callable(getattr(lf.integrals, name)) diff --git a/tests/test_photometry_completeness.py b/tests/test_lumfuncs_completeness.py similarity index 97% rename from tests/test_photometry_completeness.py rename to tests/test_lumfuncs_completeness.py index 4acc5d05..27805232 100644 --- a/tests/test_photometry_completeness.py +++ b/tests/test_lumfuncs_completeness.py @@ -3,7 +3,7 @@ import numpy as np import pytest -import lfkit.photometry.catalog_completeness as cc +import lfkit.luminosity_functions.completeness as cc def constant_lf(m_abs: np.ndarray, z: np.ndarray) -> np.ndarray: @@ -236,7 +236,7 @@ def test_missing_number_density_rejects_invalid_magnitude_range() -> None: ) -def test_catalog_completeness_fraction_returns_observed_fraction( +def test_catalog_fraction_returns_observed_fraction( monkeypatch: pytest.MonkeyPatch, ) -> None: """Tests that catalog completeness returns observed over total density.""" @@ -246,7 +246,7 @@ def test_catalog_completeness_fraction_returns_observed_fraction( lambda *args, **kwargs: np.array([-21.0, -19.0]), ) - result = cc.catalog_completeness_fraction( + result = cc.catalog_fraction( object(), [0.1, 0.2], constant_lf, @@ -306,7 +306,7 @@ def test_observed_number_density_accepts_scalar_redshift( assert result == pytest.approx(3.0) -def test_catalog_completeness_fraction_returns_zero_for_zero_total_density( +def test_catalog_fraction_returns_zero_for_zero_total_density( monkeypatch: pytest.MonkeyPatch, ) -> None: """Tests that completeness is zero when total density is zero.""" @@ -320,7 +320,7 @@ def zero_lf(m_abs: np.ndarray, z: np.ndarray) -> np.ndarray: lambda *args, **kwargs: np.array([-21.0, -19.0]), ) - result = cc.catalog_completeness_fraction( + result = cc.catalog_fraction( object(), [0.1, 0.2], zero_lf, @@ -343,7 +343,7 @@ def test_completeness_and_out_of_catalog_fractions_sum_to_one( lambda *args, **kwargs: np.array([-21.0, -19.0]), ) - completeness = cc.catalog_completeness_fraction( + completeness = cc.catalog_fraction( object(), [0.1, 0.2], constant_lf, diff --git a/tests/test_photometry_completeness_fake_catalog.py b/tests/test_lumfuncs_completeness_fake_catalog.py similarity index 95% rename from tests/test_photometry_completeness_fake_catalog.py rename to tests/test_lumfuncs_completeness_fake_catalog.py index 271bfe88..665f0173 100644 --- a/tests/test_photometry_completeness_fake_catalog.py +++ b/tests/test_lumfuncs_completeness_fake_catalog.py @@ -7,8 +7,8 @@ import numpy as np import pyccl as ccl -from lfkit.photometry.catalog_completeness import ( - catalog_completeness_fraction, +from lfkit.luminosity_functions.completeness import ( + catalog_fraction, missing_number_density, observed_number_density, out_of_catalog_fraction, @@ -73,7 +73,7 @@ def test_completeness_fraction_on_fake_catalog_redshifts() -> None: z = np.asarray(catalog["z"], dtype=float) - completeness = catalog_completeness_fraction( + completeness = catalog_fraction( cosmo, z, toy_luminosity_function, @@ -96,7 +96,7 @@ def test_observed_and_missing_fractions_sum_to_one() -> None: z = np.asarray(catalog["z"], dtype=float) - completeness = catalog_completeness_fraction( + completeness = catalog_fraction( cosmo, z, toy_luminosity_function, @@ -130,7 +130,7 @@ def test_deeper_catalog_limit_increases_completeness() -> None: z = np.asarray(catalog["z"], dtype=float) - shallow = catalog_completeness_fraction( + shallow = catalog_fraction( cosmo, z, toy_luminosity_function, @@ -139,7 +139,7 @@ def test_deeper_catalog_limit_increases_completeness() -> None: m_faint=-14.0, n_m=1024, ) - deep = catalog_completeness_fraction( + deep = catalog_fraction( cosmo, z, toy_luminosity_function, diff --git a/tests/test_photometry_conditional_lf_integrals.py b/tests/test_lumfuncs_conditional_integrals.py similarity index 99% rename from tests/test_photometry_conditional_lf_integrals.py rename to tests/test_lumfuncs_conditional_integrals.py index e6f36c2b..0ea963e8 100644 --- a/tests/test_photometry_conditional_lf_integrals.py +++ b/tests/test_lumfuncs_conditional_integrals.py @@ -3,7 +3,7 @@ import numpy as np import pytest -from lfkit.photometry.conditional_lf_integrals import ( +from lfkit.luminosity_functions.conditional_integrals import ( evaluate_conditional_luminosity_function, integrate_conditional_luminosity_function, integrate_weighted_conditional_luminosity_function, diff --git a/tests/test_photometry_conditional_lf_models.py b/tests/test_lumfuncs_conditional_models.py similarity index 63% rename from tests/test_photometry_conditional_lf_models.py rename to tests/test_lumfuncs_conditional_models.py index e157d26c..c22e1e52 100644 --- a/tests/test_photometry_conditional_lf_models.py +++ b/tests/test_lumfuncs_conditional_models.py @@ -3,19 +3,15 @@ import numpy as np import pytest -from lfkit.photometry.conditional_lf_models import ( - conditional_schechter, - conditional_double_schechter, - conditional_evolving_schechter, - lognormal_conditional_lf, - modified_schechter_conditional_lf, - two_component_conditional_lf, -) -from lfkit.photometry.luminosities import ( - luminosity_ratio, - magnitude_difference_from_luminosity_ratio, -) -from lfkit.photometry.luminosity_function import schechter, double_schechter +from lfkit.luminosity_functions.models.composite import two_component_lf +from lfkit.luminosity_functions.models.schechter import double_schechter, schechter +from lfkit.luminosity_functions.registry import get_conditional_lf_model + + +conditional_schechter = get_conditional_lf_model("schechter").function +conditional_double_schechter = get_conditional_lf_model("double_schechter").function +conditional_lognormal_lf = get_conditional_lf_model("lognormal").function +conditional_two_component_lf = get_conditional_lf_model("two_component").function def test_conditional_schechter_matches_schechter_for_scalar_parameters() -> None: @@ -94,50 +90,6 @@ def test_conditional_schechter_rejects_non_finite_callable_parameter() -> None: ) -def test_conditional_evolving_schechter_matches_explicit_parameter_models() -> None: - """Tests the conditional evolving Schechter wrapper with simple models.""" - - absolute_mag = np.array([-22.0, -21.0, -20.0]) - condition = np.array([0.0, 1.0, 2.0]) - - result = conditional_evolving_schechter( - absolute_mag=absolute_mag, - condition=condition, - phi_model="constant", - phi_kwargs={"phi_star": 1.0e-3}, - m_star_model="constant", - m_star_kwargs={"m_star": -21.0}, - alpha_model="constant", - alpha_kwargs={"alpha": -1.1}, - ) - - expected = schechter( - absolute_mag, - phi_star=1.0e-3, - m_star=-21.0, - alpha=-1.1, - ) - - np.testing.assert_allclose(result, expected) - assert result.dtype == np.float64 - - -def test_conditional_evolving_schechter_rejects_unknown_model() -> None: - """Tests that unknown LF parameter models are rejected.""" - - with pytest.raises(ValueError): - conditional_evolving_schechter( - absolute_mag=[-22.0, -21.0, -20.0], - condition=[0.0, 1.0, 2.0], - phi_model="not_a_model", - phi_kwargs={"value": 1.0e-3}, - m_star_model="constant", - m_star_kwargs={"value": -21.0}, - alpha_model="constant", - alpha_kwargs={"value": -1.1}, - ) - - def test_conditional_double_schechter_matches_double_schechter() -> None: """Tests that the conditional double-Schechter wrapper matches the model.""" @@ -196,7 +148,7 @@ def test_conditional_double_schechter_accepts_callable_parameters() -> None: assert result.dtype == np.float64 -def test_lognormal_conditional_lf_matches_expected_formula() -> None: +def test_conditional_lognormal_lf_matches_expected_formula() -> None: """Tests the lognormal conditional LF formula.""" absolute_mag = np.array([-22.0, -21.0, -20.0]) @@ -205,7 +157,7 @@ def test_lognormal_conditional_lf_matches_expected_formula() -> None: sigma_log_luminosity = np.array([0.2, 0.2, 0.2]) amplitude = np.array([1.0, 2.0, 3.0]) - result = lognormal_conditional_lf( + result = conditional_lognormal_lf( absolute_mag=absolute_mag, condition=condition, mean_absolute_mag=mean_absolute_mag, @@ -225,13 +177,13 @@ def test_lognormal_conditional_lf_matches_expected_formula() -> None: assert result.dtype == np.float64 -def test_lognormal_conditional_lf_accepts_callable_parameters() -> None: +def test_conditional_lognormal_lf_accepts_callable_parameters() -> None: """Tests callable parameters for the lognormal conditional LF.""" absolute_mag = np.array([-22.0, -21.0, -20.0]) condition = np.array([0.0, 1.0, 2.0]) - result = lognormal_conditional_lf( + result = conditional_lognormal_lf( absolute_mag=absolute_mag, condition=condition, mean_absolute_mag=lambda x: -21.0 - 0.1 * x, @@ -255,11 +207,11 @@ def test_lognormal_conditional_lf_accepts_callable_parameters() -> None: assert result.dtype == np.float64 -def test_lognormal_conditional_lf_rejects_zero_sigma() -> None: +def test_conditional_lognormal_lf_rejects_zero_sigma() -> None: """Tests that zero lognormal scatter is rejected.""" with pytest.raises(ValueError, match="sigma_log_luminosity must be positive."): - lognormal_conditional_lf( + conditional_lognormal_lf( absolute_mag=[-22.0, -21.0, -20.0], condition=[0.0, 1.0, 2.0], mean_absolute_mag=-21.0, @@ -268,11 +220,11 @@ def test_lognormal_conditional_lf_rejects_zero_sigma() -> None: ) -def test_lognormal_conditional_lf_rejects_negative_sigma() -> None: +def test_conditional_lognormal_lf_rejects_negative_sigma() -> None: """Tests that negative lognormal scatter is rejected.""" with pytest.raises(ValueError, match="sigma_log_luminosity must be positive."): - lognormal_conditional_lf( + conditional_lognormal_lf( absolute_mag=[-22.0, -21.0, -20.0], condition=[0.0, 1.0, 2.0], mean_absolute_mag=-21.0, @@ -281,11 +233,11 @@ def test_lognormal_conditional_lf_rejects_negative_sigma() -> None: ) -def test_lognormal_conditional_lf_rejects_negative_amplitude() -> None: +def test_conditional_lognormal_lf_rejects_negative_amplitude() -> None: """Tests that negative lognormal amplitude is rejected.""" with pytest.raises(ValueError, match="amplitude must be non-negative."): - lognormal_conditional_lf( + conditional_lognormal_lf( absolute_mag=[-22.0, -21.0, -20.0], condition=[0.0, 1.0, 2.0], mean_absolute_mag=-21.0, @@ -294,79 +246,13 @@ def test_lognormal_conditional_lf_rejects_negative_amplitude() -> None: ) -def test_modified_schechter_conditional_lf_matches_expected_formula() -> None: - """Tests the modified-Schechter conditional LF formula.""" - - absolute_mag = np.array([-22.0, -21.0, -20.0]) - condition = np.array([0.0, 1.0, 2.0]) - phi_star = np.array([1.0e-3, 2.0e-3, 3.0e-3]) - m_star = np.array([-21.0, -21.1, -21.2]) - alpha = np.array([-1.0, -1.1, -1.2]) - - result = modified_schechter_conditional_lf( - absolute_mag=absolute_mag, - condition=condition, - phi_star=phi_star, - m_star=m_star, - alpha=alpha, - ) - - x = luminosity_ratio(absolute_mag, m_star) - expected = 0.4 * np.log(10.0) * phi_star * x ** (alpha + 1.0) * np.exp( - -(x**2.0) - ) - - np.testing.assert_allclose(result, expected) - assert result.dtype == np.float64 - - -def test_modified_schechter_conditional_lf_accepts_callable_parameters() -> None: - """Tests callable parameters for the modified-Schechter conditional LF.""" - - absolute_mag = np.array([-22.0, -21.0, -20.0]) - condition = np.array([0.0, 1.0, 2.0]) - - result = modified_schechter_conditional_lf( - absolute_mag=absolute_mag, - condition=condition, - phi_star=lambda x: 1.0e-3 * (1.0 + x), - m_star=lambda x: -21.0 - 0.1 * x, - alpha=lambda x: -1.0 - 0.05 * x, - ) - - phi_star = np.array([1.0e-3, 2.0e-3, 3.0e-3]) - m_star = np.array([-21.0, -21.1, -21.2]) - alpha = np.array([-1.0, -1.05, -1.1]) - - x = luminosity_ratio(absolute_mag, m_star) - expected = 0.4 * np.log(10.0) * phi_star * x ** (alpha + 1.0) * np.exp( - -(x**2.0) - ) - - np.testing.assert_allclose(result, expected) - assert result.dtype == np.float64 - - -def test_modified_schechter_conditional_lf_rejects_negative_phi_star() -> None: - """Tests that negative modified-Schechter normalization is rejected.""" - - with pytest.raises(ValueError, match="phi_star must be non-negative."): - modified_schechter_conditional_lf( - absolute_mag=[-22.0, -21.0, -20.0], - condition=[0.0, 1.0, 2.0], - phi_star=-1.0e-3, - m_star=-21.0, - alpha=-1.1, - ) - - -def test_two_component_conditional_lf_equals_sum_with_explicit_modified_m_star() -> None: +def test_conditional_two_component_lf_equals_sum_with_explicit_modified_m_star() -> None: """Tests that the two-component LF equals lognormal plus modified parts.""" absolute_mag = np.array([-22.0, -21.0, -20.0]) condition = np.array([0.0, 1.0, 2.0]) - result = two_component_conditional_lf( + result = conditional_two_component_lf( absolute_mag=absolute_mag, condition=condition, lognormal_mean_absolute_mag=-21.0, @@ -377,27 +263,21 @@ def test_two_component_conditional_lf_equals_sum_with_explicit_modified_m_star() modified_m_star=-20.5, ) - lognormal = lognormal_conditional_lf( - absolute_mag=absolute_mag, - condition=condition, - mean_absolute_mag=-21.0, - sigma_log_luminosity=0.2, - amplitude=1.0, - ) - modified = modified_schechter_conditional_lf( - absolute_mag=absolute_mag, - condition=condition, - phi_star=1.0e-3, - m_star=-20.5, - alpha=-1.1, + expected = two_component_lf( + absolute_mag, + lognormal_mean_absolute_mag=-21.0, + lognormal_sigma_log_luminosity=0.2, + modified_phi_star=1.0e-3, + modified_alpha=-1.1, + lognormal_amplitude=1.0, + modified_m_star=-20.5, ) - expected = lognormal + modified np.testing.assert_allclose(result, expected) assert result.dtype == np.float64 -def test_two_component_conditional_lf_derives_modified_m_star() -> None: +def test_conditional_two_component_lf_derives_modified_m_star() -> None: """Tests that modified M-star is derived from the luminosity fraction.""" absolute_mag = np.array([-22.0, -21.0, -20.0]) @@ -405,7 +285,7 @@ def test_two_component_conditional_lf_derives_modified_m_star() -> None: lognormal_mean_absolute_mag = np.array([-21.0, -21.1, -21.2]) modified_luminosity_fraction = np.array([0.5, 0.6, 0.7]) - result = two_component_conditional_lf( + result = conditional_two_component_lf( absolute_mag=absolute_mag, condition=condition, lognormal_mean_absolute_mag=lognormal_mean_absolute_mag, @@ -417,38 +297,29 @@ def test_two_component_conditional_lf_derives_modified_m_star() -> None: modified_luminosity_fraction=modified_luminosity_fraction, ) - modified_m_star = lognormal_mean_absolute_mag + ( - magnitude_difference_from_luminosity_ratio(modified_luminosity_fraction) - ) - - lognormal = lognormal_conditional_lf( - absolute_mag=absolute_mag, - condition=condition, - mean_absolute_mag=lognormal_mean_absolute_mag, - sigma_log_luminosity=0.2, - amplitude=1.0, - ) - modified = modified_schechter_conditional_lf( - absolute_mag=absolute_mag, - condition=condition, - phi_star=1.0e-3, - m_star=modified_m_star, - alpha=-1.1, + expected = two_component_lf( + absolute_mag, + lognormal_mean_absolute_mag=lognormal_mean_absolute_mag, + lognormal_sigma_log_luminosity=0.2, + modified_phi_star=1.0e-3, + modified_alpha=-1.1, + lognormal_amplitude=1.0, + modified_m_star=None, + modified_luminosity_fraction=modified_luminosity_fraction, ) - expected = lognormal + modified np.testing.assert_allclose(result, expected) assert result.dtype == np.float64 -def test_two_component_conditional_lf_rejects_zero_luminosity_fraction() -> None: +def test_conditional_two_component_lf_rejects_zero_luminosity_fraction() -> None: """Tests that zero modified luminosity fraction is rejected.""" with pytest.raises( ValueError, match="modified_luminosity_fraction must be positive.", ): - two_component_conditional_lf( + conditional_two_component_lf( absolute_mag=[-22.0, -21.0, -20.0], condition=[0.0, 1.0, 2.0], lognormal_mean_absolute_mag=-21.0, @@ -461,14 +332,14 @@ def test_two_component_conditional_lf_rejects_zero_luminosity_fraction() -> None ) -def test_two_component_conditional_lf_rejects_negative_luminosity_fraction() -> None: +def test_conditional_two_component_lf_rejects_negative_luminosity_fraction() -> None: """Tests that negative modified luminosity fraction is rejected.""" with pytest.raises( ValueError, match="modified_luminosity_fraction must be positive.", ): - two_component_conditional_lf( + conditional_two_component_lf( absolute_mag=[-22.0, -21.0, -20.0], condition=[0.0, 1.0, 2.0], lognormal_mean_absolute_mag=-21.0, @@ -481,11 +352,11 @@ def test_two_component_conditional_lf_rejects_negative_luminosity_fraction() -> ) -def test_two_component_conditional_lf_propagates_invalid_lognormal_component() -> None: +def test_conditional_two_component_lf_propagates_invalid_lognormal_component() -> None: """Tests that invalid lognormal-component parameters are propagated.""" with pytest.raises(ValueError, match="sigma_log_luminosity must be positive."): - two_component_conditional_lf( + conditional_two_component_lf( absolute_mag=[-22.0, -21.0, -20.0], condition=[0.0, 1.0, 2.0], lognormal_mean_absolute_mag=-21.0, @@ -497,11 +368,11 @@ def test_two_component_conditional_lf_propagates_invalid_lognormal_component() - ) -def test_two_component_conditional_lf_propagates_invalid_modified_component() -> None: +def test_conditional_two_component_lf_propagates_invalid_modified_component() -> None: """Tests that invalid modified-component parameters are propagated.""" with pytest.raises(ValueError, match="phi_star must be non-negative."): - two_component_conditional_lf( + conditional_two_component_lf( absolute_mag=[-22.0, -21.0, -20.0], condition=[0.0, 1.0, 2.0], lognormal_mean_absolute_mag=-21.0, diff --git a/tests/test_photometry_lf_integrals.py b/tests/test_lumfuncs_integrals.py similarity index 74% rename from tests/test_photometry_lf_integrals.py rename to tests/test_lumfuncs_integrals.py index e32a09dc..388cf7d6 100644 --- a/tests/test_photometry_lf_integrals.py +++ b/tests/test_lumfuncs_integrals.py @@ -3,7 +3,7 @@ import numpy as np import pytest -import lfkit.photometry.lf_integrals as li +import lfkit.luminosity_functions.integrals as li def constant_lf(m_abs: np.ndarray, z: np.ndarray) -> np.ndarray: @@ -604,3 +604,213 @@ def e_correction_fn(z: np.ndarray) -> np.ndarray: ) np.testing.assert_allclose(result, np.array([5.5, 5.5])) + + +def test_luminosity_weight_matches_expected_scaling() -> None: + """Tests that luminosity weights follow the expected magnitude scaling.""" + magnitudes = np.array([0.0, 2.5, 5.0]) + result = li.luminosity_weight(magnitudes) + expected = np.array([1.0, 0.1, 0.01]) + np.testing.assert_allclose(result, expected) + + +def test_luminosity_weight_respects_reference_magnitude() -> None: + """Tests that the reference magnitude shifts the luminosity zero-point.""" + result = li.luminosity_weight( + np.array([1.0, 2.0]), + m_reference=1.0, + ) + expected = np.array([1.0, 10.0 ** (-0.4)]) + np.testing.assert_allclose(result, expected) + + +def test_luminosity_weight_rejects_nonfinite_reference() -> None: + """Tests that non-finite luminosity-weight references raise ValueError.""" + with pytest.raises(ValueError, match="m_reference must be finite"): + li.luminosity_weight(-20.0, m_reference=np.nan) + + +def test_selection_fraction_returns_selected_over_total_density() -> None: + """Tests that selection fraction returns selected density over total density.""" + result = li.selection_fraction( + [0.1, 0.2], + constant_lf, + m_selected_bright=-22.0, + m_selected_faint=-18.0, + m_total_bright=-24.0, + m_total_faint=-18.0, + n_m=64, + ) + + np.testing.assert_allclose(result, np.array([4.0 / 6.0, 4.0 / 6.0])) + + +def test_selection_fraction_supports_redshift_dependent_bounds() -> None: + """Tests that selection fraction supports redshift-dependent windows.""" + result = li.selection_fraction( + [0.1, 0.2, 0.3], + constant_lf, + m_selected_bright=np.array([-23.0, -22.0, -21.0]), + m_selected_faint=-18.0, + m_total_bright=-24.0, + m_total_faint=-18.0, + n_m=64, + ) + + np.testing.assert_allclose(result, np.array([5.0 / 6.0, 4.0 / 6.0, 3.0 / 6.0])) + + +def test_selection_fraction_returns_zero_for_zero_total_density() -> None: + """Tests that selection fraction is zero when total density is zero.""" + result = li.selection_fraction( + [0.1, 0.2], + zero_lf, + m_selected_bright=-22.0, + m_selected_faint=-18.0, + m_total_bright=-24.0, + m_total_faint=-18.0, + n_m=64, + ) + + np.testing.assert_allclose(result, np.array([0.0, 0.0])) + + +def test_cumulative_selection_function_brighter_than_threshold() -> None: + """Tests that brighter-than selection equals cumulative over total density.""" + result = li.cumulative_selection_function( + [0.1, 0.2], + constant_lf, + m_threshold=-21.0, + m_bright=-24.0, + m_faint=-18.0, + brighter_than=True, + n_m=64, + ) + + np.testing.assert_allclose(result, np.array([0.5, 0.5])) + + +def test_cumulative_selection_function_fainter_than_threshold() -> None: + """Tests that fainter-than selection equals cumulative over total density.""" + result = li.cumulative_selection_function( + [0.1, 0.2], + constant_lf, + m_threshold=-21.0, + m_bright=-24.0, + m_faint=-18.0, + brighter_than=False, + n_m=64, + ) + + np.testing.assert_allclose(result, np.array([0.5, 0.5])) + + +def test_cumulative_selection_function_is_one_for_faint_brighter_than_cut() -> None: + """Tests that brighter-than selection is one for a cut past the faint bound.""" + result = li.cumulative_selection_function( + [0.1, 0.2], + constant_lf, + m_threshold=-17.0, + m_bright=-24.0, + m_faint=-18.0, + brighter_than=True, + n_m=64, + ) + + np.testing.assert_allclose(result, np.array([1.0, 1.0])) + + +def test_cumulative_selection_function_is_zero_for_bright_brighter_than_cut() -> None: + """Tests that brighter-than selection is zero for a cut past the bright bound.""" + result = li.cumulative_selection_function( + [0.1, 0.2], + constant_lf, + m_threshold=-25.0, + m_bright=-24.0, + m_faint=-18.0, + brighter_than=True, + n_m=64, + ) + + np.testing.assert_allclose(result, np.array([0.0, 0.0])) + + +def test_cumulative_selection_function_supports_array_thresholds() -> None: + """Tests that cumulative selection supports redshift-dependent thresholds.""" + result = li.cumulative_selection_function( + [0.1, 0.2, 0.3], + constant_lf, + m_threshold=np.array([-22.0, -21.0, -20.0]), + m_bright=-24.0, + m_faint=-18.0, + brighter_than=True, + n_m=64, + ) + + np.testing.assert_allclose(result, np.array([2.0 / 6.0, 3.0 / 6.0, 4.0 / 6.0])) + + +def test_cumulative_selection_function_returns_zero_for_zero_total_density() -> None: + """Tests that cumulative selection is zero when total density is zero.""" + result = li.cumulative_selection_function( + [0.1, 0.2], + zero_lf, + m_threshold=-21.0, + m_bright=-24.0, + m_faint=-18.0, + brighter_than=True, + n_m=64, + ) + + np.testing.assert_allclose(result, np.array([0.0, 0.0])) + + +def test_private_bind_static_lf_wraps_redshift_independent_model() -> None: + """Tests that static LF binding ignores redshift and preserves parameters.""" + + def static_model(m_abs: np.ndarray, *, amplitude: float) -> np.ndarray: + """Return a static constant LF.""" + return amplitude * np.ones_like(m_abs, dtype=float) + + lf = li._bind_static_lf(static_model, amplitude=3.0) + + result = li.integrated_number_density( + [0.1, 0.2], + lf, + m_bright=-24.0, + m_faint=-18.0, + n_m=64, + ) + + np.testing.assert_allclose(result, np.array([18.0, 18.0])) + + +def test_private_bind_lf_wraps_redshift_dependent_model() -> None: + """Tests that LF binding passes redshift and preserves parameters.""" + + def evolving_model( + m_abs: np.ndarray, + z: np.ndarray, + *, + amplitude: float, + ) -> np.ndarray: + """Return a redshift-dependent constant LF.""" + return amplitude * (1.0 + z) * np.ones_like(m_abs, dtype=float) + + lf = li._bind_lf(evolving_model, amplitude=2.0) + + result = li.integrated_number_density( + [0.0, 1.0], + lf, + m_bright=-24.0, + m_faint=-18.0, + n_m=64, + ) + + np.testing.assert_allclose(result, np.array([12.0, 24.0])) + + +def test_api_aliases_cover_public_exports() -> None: + """Tests that public integral functions are included in API aliases.""" + missing_aliases = set(li.__all__) - set(li.__api_aliases__) + assert missing_aliases == set() diff --git a/tests/test_photometry_luminosity_function.py b/tests/test_lumfuncs_models_schechter.py similarity index 99% rename from tests/test_photometry_luminosity_function.py rename to tests/test_lumfuncs_models_schechter.py index 01a1e0f7..3c6c955c 100644 --- a/tests/test_photometry_luminosity_function.py +++ b/tests/test_lumfuncs_models_schechter.py @@ -4,7 +4,7 @@ import pytest from lfkit.photometry.luminosities import luminosity_ratio -from lfkit.photometry.luminosity_function import ( +from lfkit.luminosity_functions.models.schechter import ( schechter, evolving_schechter, double_schechter, diff --git a/tests/test_photometry_lf_parameter_models.py b/tests/test_lumfuncs_parameter_models.py similarity index 99% rename from tests/test_photometry_lf_parameter_models.py rename to tests/test_lumfuncs_parameter_models.py index b7017f88..98c035c7 100644 --- a/tests/test_photometry_lf_parameter_models.py +++ b/tests/test_lumfuncs_parameter_models.py @@ -5,7 +5,7 @@ import numpy as np import pytest -from lfkit.photometry.lf_parameter_models import ( +from lfkit.luminosity_functions.parameter_models import ( ALPHA_MODELS, M_STAR_MODELS, PHI_STAR_MODELS, diff --git a/tests/test_photometry_lf_redshift_density.py b/tests/test_lumfuncs_redshift_density.py similarity index 99% rename from tests/test_photometry_lf_redshift_density.py rename to tests/test_lumfuncs_redshift_density.py index 1ec1330c..661755e0 100644 --- a/tests/test_photometry_lf_redshift_density.py +++ b/tests/test_lumfuncs_redshift_density.py @@ -3,7 +3,7 @@ import numpy as np import pytest -import lfkit.photometry.lf_redshift_density as lfrd +import lfkit.luminosity_functions.redshift_density as lfrd M_LIM = 26.0 diff --git a/tests/test_photometry_luminosities.py b/tests/test_photometry_luminosities.py index 7dee8b81..38398c27 100644 --- a/tests/test_photometry_luminosities.py +++ b/tests/test_photometry_luminosities.py @@ -11,11 +11,6 @@ luminosity_ratio_from_magnitudes, luminosity_weight_from_magnitude, magnitude_difference_from_luminosity_ratio, - sample_schechter_luminosity, - schechter_cumulative_number_density_luminosity, - schechter_luminosity_density, - schechter_mean_luminosity, - schechter_selection_function, ) @@ -85,293 +80,3 @@ def test_luminosity_from_magnitude_raises_for_non_positive_reference_luminosity( with pytest.raises(ValueError, match="strictly positive"): luminosity_from_magnitude(-20.0, reference_luminosity=-1.0) - - -def test_schechter_cumulative_number_density_matches_analytic_expression() -> None: - """Tests that cumulative number density matches the incomplete-gamma form.""" - luminosity_min = np.array([0.0, 0.5, 1.0, 3.0]) - phi_star = 2.0 - l_star = 4.0 - alpha = -0.2 - - s = alpha + 1.0 - x_min = luminosity_min / l_star - expected = phi_star * gamma(s) * gammaincc(s, x_min) - - result = schechter_cumulative_number_density_luminosity( - luminosity_min, - phi_star=phi_star, - l_star=l_star, - alpha=alpha, - ) - np.testing.assert_allclose(result, expected) - - -def test_schechter_cumulative_number_density_at_zero_threshold_is_total_density() -> None: - """Tests that zero threshold returns the total number density.""" - phi_star = 1.7 - l_star = 2.3 - alpha = -0.4 - expected = phi_star * gamma(alpha + 1.0) - - result = schechter_cumulative_number_density_luminosity( - 0.0, - phi_star=phi_star, - l_star=l_star, - alpha=alpha, - ) - np.testing.assert_allclose(result, expected) - - -@pytest.mark.parametrize( - ("luminosity_min", "phi_star", "l_star", "alpha"), - [ - (-1.0, 1.0, 2.0, -0.5), - (0.0, -1.0, 2.0, -0.5), - (0.0, 1.0, 0.0, -0.5), - (0.0, 1.0, 2.0, -1.0), - (0.0, 1.0, 2.0, -1.5), - ], -) -def test_schechter_cumulative_number_density_rejects_invalid_inputs( - luminosity_min: float, - phi_star: float, - l_star: float, - alpha: float, -) -> None: - """Tests that invalid cumulative-density inputs raise ValueError.""" - with pytest.raises(ValueError): - schechter_cumulative_number_density_luminosity( - luminosity_min, - phi_star=phi_star, - l_star=l_star, - alpha=alpha, - ) - - -def test_schechter_luminosity_density_matches_analytic_formula() -> None: - """Tests that luminosity density matches the Schechter analytic formula.""" - phi_star = 1.5 - l_star = 3.0 - alpha = -0.7 - expected = phi_star * l_star * gamma(alpha + 2.0) - - result = schechter_luminosity_density( - phi_star=phi_star, - l_star=l_star, - alpha=alpha, - ) - np.testing.assert_allclose(result, expected) - - -@pytest.mark.parametrize( - ("phi_star", "l_star", "alpha"), - [ - (-1.0, 2.0, -0.5), - (1.0, 0.0, -0.5), - (1.0, 2.0, -2.0), - (1.0, 2.0, -3.0), - ], -) -def test_schechter_luminosity_density_rejects_invalid_inputs( - phi_star: float, - l_star: float, - alpha: float, -) -> None: - """Tests that invalid luminosity-density inputs raise ValueError.""" - with pytest.raises(ValueError): - schechter_luminosity_density( - phi_star=phi_star, - l_star=l_star, - alpha=alpha, - ) - - -def test_schechter_mean_luminosity_matches_simplified_expression() -> None: - """Tests that mean luminosity equals l_star times alpha plus one.""" - l_star = 5.0 - alpha = 0.3 - expected = l_star * (alpha + 1.0) - - result = schechter_mean_luminosity( - l_star=l_star, - alpha=alpha, - ) - np.testing.assert_allclose(result, expected) - - -@pytest.mark.parametrize( - ("l_star", "alpha"), - [ - (0.0, -0.5), - (-1.0, -0.5), - (1.0, -1.0), - (1.0, -1.2), - ], -) -def test_schechter_mean_luminosity_rejects_invalid_inputs( - l_star: float, - alpha: float, -) -> None: - """Tests that invalid mean-luminosity inputs raise ValueError.""" - with pytest.raises(ValueError): - schechter_mean_luminosity( - l_star=l_star, - alpha=alpha, - ) - - -def test_sample_schechter_luminosity_is_reproducible_with_seeded_rng() -> None: - """Tests that seeded sampling is reproducible.""" - rng_1 = np.random.default_rng(12345) - rng_2 = np.random.default_rng(12345) - - sample_1 = sample_schechter_luminosity( - 8, - l_star=2.0, - alpha=0.4, - rng=rng_1, - ) - sample_2 = sample_schechter_luminosity( - 8, - l_star=2.0, - alpha=0.4, - rng=rng_2, - ) - - np.testing.assert_allclose(sample_1, sample_2) - - -def test_sample_schechter_luminosity_returns_positive_samples_with_requested_shape() -> None: - """Tests that sampled luminosities are positive and follow the requested shape.""" - rng = np.random.default_rng(7) - samples = sample_schechter_luminosity( - (4, 3), - l_star=1.5, - alpha=0.2, - rng=rng, - ) - - assert samples.shape == (4, 3) - assert np.all(samples > 0.0) - - -def test_sample_schechter_luminosity_has_correct_mean_in_large_sample() -> None: - """Tests that sampled luminosities recover the analytic mean on average.""" - rng = np.random.default_rng(42) - l_star = 2.5 - alpha = 0.4 - expected_mean = schechter_mean_luminosity(l_star=l_star, alpha=alpha) - - samples = sample_schechter_luminosity( - 200_000, - l_star=l_star, - alpha=alpha, - rng=rng, - ) - - assert np.isclose(np.mean(samples), expected_mean, rtol=2e-2) - - -@pytest.mark.parametrize( - ("l_star", "alpha"), - [ - (0.0, 0.1), - (-1.0, 0.1), - (1.0, -1.0), - (1.0, -1.3), - ], -) -def test_sample_schechter_luminosity_rejects_invalid_inputs( - l_star: float, - alpha: float, -) -> None: - """Tests that invalid sampling inputs raise ValueError.""" - with pytest.raises(ValueError): - sample_schechter_luminosity( - 5, - l_star=l_star, - alpha=alpha, - ) - - -def test_schechter_selection_function_matches_regularized_incomplete_gamma() -> None: - """Tests that the selection function matches the regularized gamma form.""" - luminosity_min = np.array([0.0, 0.5, 1.0, 2.0]) - l_star = 2.0 - alpha = -0.3 - s = alpha + 1.0 - expected = gammaincc(s, luminosity_min / l_star) - - result = schechter_selection_function( - luminosity_min, - l_star=l_star, - alpha=alpha, - ) - np.testing.assert_allclose(result, expected) - - -def test_schechter_selection_function_is_one_at_zero_and_decreases_with_threshold() -> None: - """Tests that the selection function starts at one and decreases monotonically.""" - thresholds = np.array([0.0, 0.3, 1.0, 3.0, 5.0]) - result = schechter_selection_function( - thresholds, - l_star=2.0, - alpha=-0.2, - ) - - np.testing.assert_allclose(result[0], 1.0) - assert np.all(result[:-1] >= result[1:]) - assert np.all((result >= 0.0) & (result <= 1.0)) - - -def test_selection_function_matches_cumulative_density_ratio() -> None: - """Tests that selection equals cumulative density divided by total density.""" - luminosity_min = np.array([0.0, 0.7, 1.5, 4.0]) - phi_star = 3.0 - l_star = 2.5 - alpha = -0.1 - - cumulative = schechter_cumulative_number_density_luminosity( - luminosity_min, - phi_star=phi_star, - l_star=l_star, - alpha=alpha, - ) - total = schechter_cumulative_number_density_luminosity( - 0.0, - phi_star=phi_star, - l_star=l_star, - alpha=alpha, - ) - selection = schechter_selection_function( - luminosity_min, - l_star=l_star, - alpha=alpha, - ) - - np.testing.assert_allclose(selection, cumulative / total) - - -@pytest.mark.parametrize( - ("luminosity_min", "l_star", "alpha"), - [ - (-1.0, 1.0, -0.5), - (0.0, 0.0, -0.5), - (0.0, -1.0, -0.5), - (0.0, 1.0, -1.0), - (0.0, 1.0, -2.0), - ], -) -def test_schechter_selection_function_rejects_invalid_inputs( - luminosity_min: float, - l_star: float, - alpha: float, -) -> None: - """Tests that invalid selection-function inputs raise ValueError.""" - with pytest.raises(ValueError): - schechter_selection_function( - luminosity_min, - l_star=l_star, - alpha=alpha, - )