diff --git a/docs/api/generated/lfkit.api.conditional_luminosity_function.rst b/docs/api/generated/lfkit.api.conditional_luminosity_function.rst deleted file mode 100644 index 5f3c06a4..00000000 --- a/docs/api/generated/lfkit.api.conditional_luminosity_function.rst +++ /dev/null @@ -1,12 +0,0 @@ -lfkit.api.conditional\_luminosity\_function -=========================================== - -.. automodule:: lfkit.api.conditional_luminosity_function - - - .. rubric:: Classes - - .. autosummary:: - - ConditionalLuminosityFunction - \ No newline at end of file diff --git a/docs/api/generated/lfkit.api.corrections.rst b/docs/api/generated/lfkit.api.corrections.rst deleted file mode 100644 index e1319fac..00000000 --- a/docs/api/generated/lfkit.api.corrections.rst +++ /dev/null @@ -1,12 +0,0 @@ -lfkit.api.corrections -===================== - -.. automodule:: lfkit.api.corrections - - - .. rubric:: Classes - - .. autosummary:: - - Corrections - \ No newline at end of file diff --git a/docs/api/generated/lfkit.api.luminosity_function.rst b/docs/api/generated/lfkit.api.luminosity_function.rst deleted file mode 100644 index 542b1bb5..00000000 --- a/docs/api/generated/lfkit.api.luminosity_function.rst +++ /dev/null @@ -1,12 +0,0 @@ -lfkit.api.luminosity\_function -============================== - -.. automodule:: lfkit.api.luminosity_function - - - .. rubric:: Classes - - .. autosummary:: - - LuminosityFunction - \ No newline at end of file diff --git a/docs/api/generated/lfkit.api.rst b/docs/api/generated/lfkit.api.rst deleted file mode 100644 index 002dfc8a..00000000 --- a/docs/api/generated/lfkit.api.rst +++ /dev/null @@ -1,15 +0,0 @@ -lfkit.api -========= - -.. automodule:: lfkit.api - - -.. rubric:: Modules - -.. autosummary:: - :toctree: - :recursive: - - conditional_luminosity_function - corrections - luminosity_function diff --git a/docs/api/generated/lfkit.corrections.color_anchors.rst b/docs/api/generated/lfkit.corrections.color_anchors.rst deleted file mode 100644 index 891f0865..00000000 --- a/docs/api/generated/lfkit.corrections.color_anchors.rst +++ /dev/null @@ -1,12 +0,0 @@ -lfkit.corrections.color\_anchors -================================ - -.. automodule:: lfkit.corrections.color_anchors - - - .. rubric:: Functions - - .. autosummary:: - - fit_coeffs_from_bandcolor - \ No newline at end of file diff --git a/docs/api/generated/lfkit.corrections.filters.rst b/docs/api/generated/lfkit.corrections.filters.rst deleted file mode 100644 index d1d70450..00000000 --- a/docs/api/generated/lfkit.corrections.filters.rst +++ /dev/null @@ -1,17 +0,0 @@ -lfkit.corrections.filters -========================= - -.. automodule:: lfkit.corrections.filters - - - .. 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/generated/lfkit.corrections.kcorrect_backend.rst b/docs/api/generated/lfkit.corrections.kcorrect_backend.rst deleted file mode 100644 index 9a8c0a6e..00000000 --- a/docs/api/generated/lfkit.corrections.kcorrect_backend.rst +++ /dev/null @@ -1,12 +0,0 @@ -lfkit.corrections.kcorrect\_backend -=================================== - -.. automodule:: lfkit.corrections.kcorrect_backend - - - .. rubric:: Functions - - .. autosummary:: - - build_kcorrect - \ No newline at end of file diff --git a/docs/api/generated/lfkit.corrections.kcorrect_from_color.rst b/docs/api/generated/lfkit.corrections.kcorrect_from_color.rst deleted file mode 100644 index 43531a9f..00000000 --- a/docs/api/generated/lfkit.corrections.kcorrect_from_color.rst +++ /dev/null @@ -1,12 +0,0 @@ -lfkit.corrections.kcorrect\_from\_color -======================================= - -.. automodule:: lfkit.corrections.kcorrect_from_color - - - .. rubric:: Functions - - .. autosummary:: - - kcorrect_from_bandcolor - \ No newline at end of file diff --git a/docs/api/generated/lfkit.corrections.kcorrect_grids.rst b/docs/api/generated/lfkit.corrections.kcorrect_grids.rst deleted file mode 100644 index 1e5e7ca8..00000000 --- a/docs/api/generated/lfkit.corrections.kcorrect_grids.rst +++ /dev/null @@ -1,14 +0,0 @@ -lfkit.corrections.kcorrect\_grids -================================= - -.. automodule:: lfkit.corrections.kcorrect_grids - - - .. rubric:: Functions - - .. autosummary:: - - build_kcorr_grid_package - compute_k_table - kcorr_interpolators - \ No newline at end of file diff --git a/docs/api/generated/lfkit.corrections.poggianti1997.rst b/docs/api/generated/lfkit.corrections.poggianti1997.rst deleted file mode 100644 index 5d77e221..00000000 --- a/docs/api/generated/lfkit.corrections.poggianti1997.rst +++ /dev/null @@ -1,21 +0,0 @@ -lfkit.corrections.poggianti1997 -=============================== - -.. automodule:: lfkit.corrections.poggianti1997 - - - .. 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/generated/lfkit.corrections.responses.rst b/docs/api/generated/lfkit.corrections.responses.rst deleted file mode 100644 index 93fe638a..00000000 --- a/docs/api/generated/lfkit.corrections.responses.rst +++ /dev/null @@ -1,16 +0,0 @@ -lfkit.corrections.responses -=========================== - -.. automodule:: lfkit.corrections.responses - - - .. 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/generated/lfkit.corrections.rst b/docs/api/generated/lfkit.corrections.rst deleted file mode 100644 index cd579d56..00000000 --- a/docs/api/generated/lfkit.corrections.rst +++ /dev/null @@ -1,19 +0,0 @@ -lfkit.corrections -================= - -.. automodule:: lfkit.corrections - - -.. rubric:: Modules - -.. autosummary:: - :toctree: - :recursive: - - color_anchors - filters - kcorrect_backend - kcorrect_from_color - kcorrect_grids - poggianti1997 - responses diff --git a/docs/api/generated/lfkit.cosmo.cosmology.rst b/docs/api/generated/lfkit.cosmo.cosmology.rst deleted file mode 100644 index d544eef4..00000000 --- a/docs/api/generated/lfkit.cosmo.cosmology.rst +++ /dev/null @@ -1,17 +0,0 @@ -lfkit.cosmo.cosmology -===================== - -.. automodule:: lfkit.cosmo.cosmology - - - .. 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/generated/lfkit.cosmo.rst b/docs/api/generated/lfkit.cosmo.rst deleted file mode 100644 index e731c99c..00000000 --- a/docs/api/generated/lfkit.cosmo.rst +++ /dev/null @@ -1,13 +0,0 @@ -lfkit.cosmo -=========== - -.. automodule:: lfkit.cosmo - - -.. rubric:: Modules - -.. autosummary:: - :toctree: - :recursive: - - cosmology diff --git a/docs/api/generated/lfkit.luminosity_functions.completeness.rst b/docs/api/generated/lfkit.luminosity_functions.completeness.rst deleted file mode 100644 index 73fa550b..00000000 --- a/docs/api/generated/lfkit.luminosity_functions.completeness.rst +++ /dev/null @@ -1,16 +0,0 @@ -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/generated/lfkit.luminosity_functions.conditional_integrals.rst b/docs/api/generated/lfkit.luminosity_functions.conditional_integrals.rst deleted file mode 100644 index 365e4c4d..00000000 --- a/docs/api/generated/lfkit.luminosity_functions.conditional_integrals.rst +++ /dev/null @@ -1,14 +0,0 @@ -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/generated/lfkit.luminosity_functions.conditional_models.rst b/docs/api/generated/lfkit.luminosity_functions.conditional_models.rst deleted file mode 100644 index a0dd91e9..00000000 --- a/docs/api/generated/lfkit.luminosity_functions.conditional_models.rst +++ /dev/null @@ -1,12 +0,0 @@ -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/generated/lfkit.luminosity_functions.integrals.rst b/docs/api/generated/lfkit.luminosity_functions.integrals.rst deleted file mode 100644 index f87da20c..00000000 --- a/docs/api/generated/lfkit.luminosity_functions.integrals.rst +++ /dev/null @@ -1,21 +0,0 @@ -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/generated/lfkit.luminosity_functions.models.composite.rst b/docs/api/generated/lfkit.luminosity_functions.models.composite.rst deleted file mode 100644 index e2694b06..00000000 --- a/docs/api/generated/lfkit.luminosity_functions.models.composite.rst +++ /dev/null @@ -1,13 +0,0 @@ -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/generated/lfkit.luminosity_functions.models.gaussian.rst b/docs/api/generated/lfkit.luminosity_functions.models.gaussian.rst deleted file mode 100644 index a7f3b86a..00000000 --- a/docs/api/generated/lfkit.luminosity_functions.models.gaussian.rst +++ /dev/null @@ -1,13 +0,0 @@ -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/generated/lfkit.luminosity_functions.models.modifiers.rst b/docs/api/generated/lfkit.luminosity_functions.models.modifiers.rst deleted file mode 100644 index ed38ada8..00000000 --- a/docs/api/generated/lfkit.luminosity_functions.models.modifiers.rst +++ /dev/null @@ -1,12 +0,0 @@ -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/generated/lfkit.luminosity_functions.models.power_law.rst b/docs/api/generated/lfkit.luminosity_functions.models.power_law.rst deleted file mode 100644 index 279379ff..00000000 --- a/docs/api/generated/lfkit.luminosity_functions.models.power_law.rst +++ /dev/null @@ -1,15 +0,0 @@ -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/generated/lfkit.luminosity_functions.models.rst b/docs/api/generated/lfkit.luminosity_functions.models.rst deleted file mode 100644 index 7be2db6f..00000000 --- a/docs/api/generated/lfkit.luminosity_functions.models.rst +++ /dev/null @@ -1,17 +0,0 @@ -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/generated/lfkit.luminosity_functions.models.schechter.rst b/docs/api/generated/lfkit.luminosity_functions.models.schechter.rst deleted file mode 100644 index 85fd5026..00000000 --- a/docs/api/generated/lfkit.luminosity_functions.models.schechter.rst +++ /dev/null @@ -1,19 +0,0 @@ -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/generated/lfkit.luminosity_functions.parameter_models.rst b/docs/api/generated/lfkit.luminosity_functions.parameter_models.rst deleted file mode 100644 index c4c9c60d..00000000 --- a/docs/api/generated/lfkit.luminosity_functions.parameter_models.rst +++ /dev/null @@ -1,23 +0,0 @@ -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/generated/lfkit.luminosity_functions.redshift_density.rst b/docs/api/generated/lfkit.luminosity_functions.redshift_density.rst deleted file mode 100644 index 892a0634..00000000 --- a/docs/api/generated/lfkit.luminosity_functions.redshift_density.rst +++ /dev/null @@ -1,13 +0,0 @@ -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/generated/lfkit.luminosity_functions.registry.rst b/docs/api/generated/lfkit.luminosity_functions.registry.rst deleted file mode 100644 index b2e394e0..00000000 --- a/docs/api/generated/lfkit.luminosity_functions.registry.rst +++ /dev/null @@ -1,24 +0,0 @@ -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/generated/lfkit.luminosity_functions.rst b/docs/api/generated/lfkit.luminosity_functions.rst deleted file mode 100644 index a8449821..00000000 --- a/docs/api/generated/lfkit.luminosity_functions.rst +++ /dev/null @@ -1,20 +0,0 @@ -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/generated/lfkit.photometry.luminosities.rst b/docs/api/generated/lfkit.photometry.luminosities.rst deleted file mode 100644 index 25cd3bc4..00000000 --- a/docs/api/generated/lfkit.photometry.luminosities.rst +++ /dev/null @@ -1,16 +0,0 @@ -lfkit.photometry.luminosities -============================= - -.. automodule:: lfkit.photometry.luminosities - - - .. 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/generated/lfkit.photometry.magnitudes.rst b/docs/api/generated/lfkit.photometry.magnitudes.rst deleted file mode 100644 index 022bbc7f..00000000 --- a/docs/api/generated/lfkit.photometry.magnitudes.rst +++ /dev/null @@ -1,16 +0,0 @@ -lfkit.photometry.magnitudes -=========================== - -.. automodule:: lfkit.photometry.magnitudes - - - .. 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/generated/lfkit.photometry.rst b/docs/api/generated/lfkit.photometry.rst deleted file mode 100644 index 60f5cd38..00000000 --- a/docs/api/generated/lfkit.photometry.rst +++ /dev/null @@ -1,14 +0,0 @@ -lfkit.photometry -================ - -.. automodule:: lfkit.photometry - - -.. rubric:: Modules - -.. autosummary:: - :toctree: - :recursive: - - luminosities - magnitudes diff --git a/docs/api/generated/lfkit.rst b/docs/api/generated/lfkit.rst deleted file mode 100644 index 1563afa1..00000000 --- a/docs/api/generated/lfkit.rst +++ /dev/null @@ -1,18 +0,0 @@ -lfkit -===== - -.. automodule:: lfkit - - -.. rubric:: Modules - -.. autosummary:: - :toctree: - :recursive: - - api - corrections - cosmo - luminosity_functions - photometry - utils diff --git a/docs/api/generated/lfkit.utils.download_poggianti97_data.rst b/docs/api/generated/lfkit.utils.download_poggianti97_data.rst deleted file mode 100644 index ae2f4b1c..00000000 --- a/docs/api/generated/lfkit.utils.download_poggianti97_data.rst +++ /dev/null @@ -1,12 +0,0 @@ -lfkit.utils.download\_poggianti97\_data -======================================= - -.. automodule:: lfkit.utils.download_poggianti97_data - - - .. rubric:: Functions - - .. autosummary:: - - main - \ No newline at end of file diff --git a/docs/api/generated/lfkit.utils.evaluators.rst b/docs/api/generated/lfkit.utils.evaluators.rst deleted file mode 100644 index 36f78e42..00000000 --- a/docs/api/generated/lfkit.utils.evaluators.rst +++ /dev/null @@ -1,16 +0,0 @@ -lfkit.utils.evaluators -====================== - -.. automodule:: lfkit.utils.evaluators - - - .. 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/generated/lfkit.utils.integrators.rst b/docs/api/generated/lfkit.utils.integrators.rst deleted file mode 100644 index ed4effc6..00000000 --- a/docs/api/generated/lfkit.utils.integrators.rst +++ /dev/null @@ -1,13 +0,0 @@ -lfkit.utils.integrators -======================= - -.. automodule:: lfkit.utils.integrators - - - .. rubric:: Functions - - .. autosummary:: - - integrate_between_variable_bounds - safe_divide - \ No newline at end of file diff --git a/docs/api/generated/lfkit.utils.interpolation.rst b/docs/api/generated/lfkit.utils.interpolation.rst deleted file mode 100644 index 7387cb57..00000000 --- a/docs/api/generated/lfkit.utils.interpolation.rst +++ /dev/null @@ -1,15 +0,0 @@ -lfkit.utils.interpolation -========================= - -.. automodule:: lfkit.utils.interpolation - - - .. 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/generated/lfkit.utils.io.rst b/docs/api/generated/lfkit.utils.io.rst deleted file mode 100644 index 0fe2d9bb..00000000 --- a/docs/api/generated/lfkit.utils.io.rst +++ /dev/null @@ -1,18 +0,0 @@ -lfkit.utils.io -============== - -.. automodule:: lfkit.utils.io - - - .. 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/generated/lfkit.utils.rst b/docs/api/generated/lfkit.utils.rst deleted file mode 100644 index 9330b50b..00000000 --- a/docs/api/generated/lfkit.utils.rst +++ /dev/null @@ -1,20 +0,0 @@ -lfkit.utils -=========== - -.. automodule:: lfkit.utils - - -.. rubric:: Modules - -.. autosummary:: - :toctree: - :recursive: - - download_poggianti97_data - evaluators - integrators - interpolation - io - types - units - validators diff --git a/docs/api/generated/lfkit.utils.types.rst b/docs/api/generated/lfkit.utils.types.rst deleted file mode 100644 index 494d5733..00000000 --- a/docs/api/generated/lfkit.utils.types.rst +++ /dev/null @@ -1,6 +0,0 @@ -lfkit.utils.types -================= - -.. automodule:: lfkit.utils.types - - \ No newline at end of file diff --git a/docs/api/generated/lfkit.utils.units.rst b/docs/api/generated/lfkit.utils.units.rst deleted file mode 100644 index b009dca8..00000000 --- a/docs/api/generated/lfkit.utils.units.rst +++ /dev/null @@ -1,17 +0,0 @@ -lfkit.utils.units -================= - -.. automodule:: lfkit.utils.units - - - .. 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/generated/lfkit.utils.validators.rst b/docs/api/generated/lfkit.utils.validators.rst deleted file mode 100644 index e905ce79..00000000 --- a/docs/api/generated/lfkit.utils.validators.rst +++ /dev/null @@ -1,14 +0,0 @@ -lfkit.utils.validators -====================== - -.. automodule:: lfkit.utils.validators - - - .. rubric:: Functions - - .. autosummary:: - - validate_array - validate_luminosity_distance - validate_magnitude_range - \ No newline at end of file diff --git a/src/lfkit/photometry/luminosities.py b/src/lfkit/photometry/luminosities.py index d54decd2..a3c627b4 100644 --- a/src/lfkit/photometry/luminosities.py +++ b/src/lfkit/photometry/luminosities.py @@ -21,6 +21,7 @@ import numpy as np from lfkit.utils.types import FloatArray, FloatInput +from lfkit.utils.integrators import safe_power10 from lfkit.utils.validators import validate_array __all__ = ( @@ -61,10 +62,8 @@ def luminosity_ratio( """ m_arr = validate_array(absolute_mag, name="absolute magnitude") m_star_arr = validate_array(m_star, name="m_star") - x = 10.0 ** (-0.4 * (m_arr - m_star_arr)) - # Clip extreme values to avoid overflow in exp later. - x = np.clip(x, 1e-300, 1e300) + x = safe_power10(-0.4 * (m_arr - m_star_arr)) return np.asarray(x, dtype=float) @@ -87,8 +86,7 @@ def luminosity_ratio_from_magnitudes( mag_1 = validate_array(magnitude, name="magnitude") mag_2 = validate_array(ref_magnitude, name="ref_magnitude") - ratio = 10.0 ** (-0.4 * (mag_1 - mag_2)) - ratio = np.clip(ratio, 1e-300, 1e300) + ratio = safe_power10(-0.4 * (mag_1 - mag_2)) return np.asarray(ratio, dtype=float) @@ -142,8 +140,9 @@ def luminosity_weight_from_magnitude( NumPy array proportional to luminosity. """ mag = validate_array(magnitude, name="magnitude") - weight = 10.0 ** (-0.4 * (mag - reference_magnitude)) - weight = np.clip(weight, 1e-300, 1e300) + + weight = safe_power10(-0.4 * (mag - reference_magnitude)) + return np.asarray(weight, dtype=float) diff --git a/src/lfkit/utils/integrators.py b/src/lfkit/utils/integrators.py index cd8f9760..72ea3158 100644 --- a/src/lfkit/utils/integrators.py +++ b/src/lfkit/utils/integrators.py @@ -18,6 +18,7 @@ __all__ = [ "integrate_between_variable_bounds", "safe_divide", + "safe_power10", ] @@ -170,3 +171,15 @@ def _bound_finite_error_message(name: str) -> str: return f"{name} must contain only finite values." return f"{name} contains NaN or infinite values." + + +def safe_power10( + exponent: FloatInput, + *, + min_exponent: float = -300.0, + max_exponent: float = 300.0, +) -> FloatArray: + """Return clipped base-10 powers without overflow warnings.""" + exponent_arr = validate_array(exponent, name="exponent") + clipped = np.clip(exponent_arr, min_exponent, max_exponent) + return np.asarray(10.0**clipped, dtype=float) diff --git a/tests/test_api_conditional_luminosity_function.py b/tests/test_api_conditional_luminosity_function.py index 9a3b0ce9..e1282190 100644 --- a/tests/test_api_conditional_luminosity_function.py +++ b/tests/test_api_conditional_luminosity_function.py @@ -1,18 +1,16 @@ -"""Smoke tests for conditional luminosity function API constructors. - -These tests check that the public conditional LF factory methods create -LuminosityFunction objects with the expected model names and parameter payloads. -They intentionally avoid testing conditional LF physics, which is covered by -the lower-level photometry tests. -""" +"""Unit tests for ``api.conditional_luminosity_function``.""" from __future__ import annotations +import numpy as np +import pytest + from lfkit.api.conditional_luminosity_function import ConditionalLuminosityFunction from lfkit.api.luminosity_function import LuminosityFunction -def test_conditional_schechter_constructor_delegates_to_luminosity_function(): +def test_conditional_schechter_constructor_stores_parameters(): + """Tests that the conditional Schechter constructor stores parameters.""" lf = ConditionalLuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, @@ -30,7 +28,8 @@ def test_conditional_schechter_constructor_delegates_to_luminosity_function(): assert lf.meta == {"source": "test"} -def test_conditional_double_schechter_constructor_delegates_to_luminosity_function(): +def test_conditional_double_schechter_constructor_stores_parameters(): + """Tests that the conditional double Schechter constructor stores parameters.""" lf = ConditionalLuminosityFunction.double_schechter( phi_star=1.0e-3, m_star=-20.5, @@ -52,7 +51,8 @@ def test_conditional_double_schechter_constructor_delegates_to_luminosity_functi assert lf.meta == {"source": "test"} -def test_lognormal_constructor_delegates_to_luminosity_function(): +def test_lognormal_constructor_stores_parameters(): + """Tests that the conditional lognormal constructor stores parameters.""" lf = ConditionalLuminosityFunction.lognormal( mean_absolute_mag=-20.5, sigma_log_luminosity=0.2, @@ -71,6 +71,7 @@ def test_lognormal_constructor_delegates_to_luminosity_function(): def test_lognormal_constructor_uses_default_amplitude(): + """Tests that the conditional lognormal constructor uses default amplitude.""" lf = ConditionalLuminosityFunction.lognormal( mean_absolute_mag=-20.5, sigma_log_luminosity=0.2, @@ -87,12 +88,12 @@ def test_lognormal_constructor_uses_default_amplitude(): def test_modified_schechter_constructor_is_not_registered() -> None: - """Tests modified Schechter is not exposed as a standalone conditional model.""" - + """Tests that modified Schechter is not a standalone conditional model.""" assert not hasattr(ConditionalLuminosityFunction, "modified_schechter") -def test_two_component_constructor_delegates_to_luminosity_function(): +def test_two_component_constructor_stores_parameters(): + """Tests that the conditional two-component constructor stores parameters.""" lf = ConditionalLuminosityFunction.two_component( lognormal_mean_absolute_mag=-21.0, lognormal_sigma_log_luminosity=0.2, @@ -119,6 +120,7 @@ def test_two_component_constructor_delegates_to_luminosity_function(): def test_two_component_constructor_uses_default_optional_parameters(): + """Tests that the two-component constructor uses optional defaults.""" lf = ConditionalLuminosityFunction.two_component( lognormal_mean_absolute_mag=-21.0, lognormal_sigma_log_luminosity=0.2, @@ -138,3 +140,68 @@ def test_two_component_constructor_uses_default_optional_parameters(): "modified_luminosity_fraction": 0.562, } assert lf.meta == {} + + +def test_available_models_includes_expected_models(): + """Tests that available models includes expected conditional models.""" + models = ConditionalLuminosityFunction.available_models() + + assert "schechter" in models + assert "double_schechter" in models + assert "lognormal" in models + assert "two_component" in models + assert models == sorted(models) + + +def test_phi_requires_condition(): + """Tests that conditional phi requires a condition.""" + lf = ConditionalLuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + ) + + with pytest.raises(ValueError, match="condition is required"): + lf.phi(-20.0) + + +def test_phi_accepts_scalar_condition(): + """Tests that conditional phi accepts scalar conditions.""" + lf = ConditionalLuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + ) + + phi = lf.phi(-20.0, condition=0.5) + + assert np.asarray(phi).shape == () + assert np.isfinite(phi) + + +def test_phi_accepts_array_condition(): + """Tests that conditional phi accepts array conditions.""" + lf = ConditionalLuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + ) + + absolute_mag = np.array([-22.0, -21.0, -20.0]) + condition = np.array([0.1, 0.5, 1.0]) + + phi = lf.phi(absolute_mag, condition=condition) + + assert phi.shape == absolute_mag.shape + assert np.all(np.isfinite(phi)) + + +def test_constructor_rejects_unexpected_parameter(): + """Tests that constructors reject unexpected parameters.""" + with pytest.raises(TypeError, match="Unexpected parameter"): + ConditionalLuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + bad_parameter=123, + ) diff --git a/tests/test_api_corrections.py b/tests/test_api_corrections.py index ed2f1a1b..97116d41 100644 --- a/tests/test_api_corrections.py +++ b/tests/test_api_corrections.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.api.corrections.py``.""" +"""Unit tests for ``lfkit.api.corrections``.""" from __future__ import annotations diff --git a/tests/test_api_luminosity_function.py b/tests/test_api_luminosity_function.py index 3165e81b..a93eff10 100644 --- a/tests/test_api_luminosity_function.py +++ b/tests/test_api_luminosity_function.py @@ -1,25 +1,32 @@ -"""Smoke tests for user-facing API delegation. - -These tests check that the public API namespaces are wired to the expected -low-level functions. They intentionally avoid testing luminosity function -physics, which is covered by the lower-level photometry tests. -""" +"""Smoke tests for the user-facing luminosity function API.""" from __future__ import annotations import inspect -import pytest import numpy as np - import pyccl as ccl +import pytest from lfkit.api._namespaces import expose_lf_function from lfkit.api.luminosity_function import LuminosityFunction from lfkit.luminosity_functions.registry import LF_MODELS +class DummyCorrections: + """Simple correction object for testing correction dispatch.""" + + def k(self, z): + """Return a simple K-correction.""" + return np.zeros_like(np.asarray(z, dtype=float)) + 0.1 + + def e(self, z): + """Return a simple evolution correction.""" + return np.zeros_like(np.asarray(z, dtype=float)) + 0.2 + + def make_test_cosmology(): + """Return a small CCL cosmology for API tests.""" return ccl.Cosmology( Omega_c=0.25, Omega_b=0.05, @@ -32,6 +39,7 @@ def make_test_cosmology(): def test_luminosity_function_initializes_grouped_api_namespaces(): + """Tests that luminosity functions initialize grouped API namespaces.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, @@ -46,6 +54,7 @@ def test_luminosity_function_initializes_grouped_api_namespaces(): def test_luminosity_function_does_not_initialize_conditional_models_namespace(): + """Tests that base luminosity functions do not expose conditional models.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, @@ -55,7 +64,66 @@ def test_luminosity_function_does_not_initialize_conditional_models_namespace(): assert not hasattr(lf, "conditional_models") +def test_constructor_stores_model_parameters_and_meta(): + """Tests that constructors store model parameters and metadata.""" + lf = LuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + meta={"source": "test"}, + ) + + assert lf.model == "schechter" + assert lf.parameters_dict == { + "phi_star": 1.0e-3, + "m_star": -20.5, + "alpha": -1.1, + } + assert lf.meta == {"source": "test"} + + +def test_constructor_uses_empty_meta_by_default(): + """Tests that constructors use empty metadata by default.""" + lf = LuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + ) + + assert lf.meta == {} + + +def test_constructor_preserves_arbitrary_parameters(): + """Tests that generic constructors preserve arbitrary parameters.""" + lf = LuminosityFunction( + model="custom", + parameters={"a": 1.0, "b": "x"}, + meta={"source": "test"}, + ) + + assert lf.model == "custom" + assert lf.parameters_dict == {"a": 1.0, "b": "x"} + assert lf.meta == {"source": "test"} + + +def test_constructor_cleans_none_kwargs_parameters(): + """Tests that constructor kwargs ending in kwargs convert None to dictionaries.""" + lf = LuminosityFunction.evolving_schechter( + phi_model="constant", + phi_kwargs=None, + m_star_model="constant", + m_star_kwargs=None, + alpha_model="constant", + alpha_kwargs=None, + ) + + assert lf.parameters_dict["phi_kwargs"] == {} + assert lf.parameters_dict["m_star_kwargs"] == {} + assert lf.parameters_dict["alpha_kwargs"] == {} + + def test_expose_lf_function_injects_lf_callable_by_position(): + """Tests that namespace wrappers inject LF callables by position.""" calls = {} def low_level(x, lf_callable, z, *, scale=1.0): @@ -86,6 +154,7 @@ def __init__(self): def test_expose_lf_function_injects_lf_callable_by_keyword(): + """Tests that namespace wrappers inject LF callables by keyword.""" calls = {} def low_level(x, z, *, lf_callable): @@ -113,20 +182,67 @@ def __init__(self): assert calls["lf_value"] == 6.0 +def test_as_callable_returns_bound_phi_function(): + """Tests that luminosity functions expose a bound callable.""" + lf = LuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + ) + + result = lf._as_callable()(-20.0, 0.5) + + assert np.asarray(result).shape == () + assert np.isfinite(result) + + def test_phi_evaluates_schechter_model(): + """Tests that phi evaluates a Schechter model.""" + lf = LuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + ) + + result = lf.phi(-20.0) + + assert np.asarray(result).shape == () + assert np.isfinite(result) + + +def test_phi_accepts_array_absolute_magnitudes(): + """Tests that phi accepts array absolute magnitudes.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, alpha=-1.1, ) + absolute_mag = np.array([-22.0, -21.0, -20.0]) + result = lf.phi(absolute_mag) + + assert result.shape == absolute_mag.shape + assert np.all(np.isfinite(result)) + + +def test_phi_evaluates_double_schechter_model(): + """Tests that phi evaluates a double Schechter model.""" + lf = LuminosityFunction.double_schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + beta=-1.5, + m_transition=-19.5, + ) + result = lf.phi(-20.0) assert np.asarray(result).shape == () assert np.isfinite(result) -def test_evolving_schechter_constructor_stores_model_and_parameter(): +def test_evolving_schechter_constructor_stores_model_and_parameters(): + """Tests that evolving Schechter constructors store model parameters.""" lf = LuminosityFunction.evolving_schechter( phi_model="constant", phi_kwargs={"phi_star": 1.0e-3}, @@ -149,22 +265,86 @@ def test_evolving_schechter_constructor_stores_model_and_parameter(): assert lf.meta == {"source": "test"} -def test_phi_evaluates_double_schechter_model(): - lf = LuminosityFunction.double_schechter( +def test_phi_requires_redshift_for_evolving_model(): + """Tests that evolving models require redshift in phi.""" + lf = LuminosityFunction.evolving_schechter() + + with pytest.raises(ValueError, match="z is required"): + lf.phi(-20.0) + + +def test_phi_evaluates_evolving_model_with_redshift(): + """Tests that evolving models evaluate when redshift is supplied.""" + lf = LuminosityFunction.evolving_schechter( + phi_model="constant", + phi_kwargs={"phi_star": 1.0e-3}, + m_star_model="constant", + m_star_kwargs={"m_star": -20.5}, + alpha_model="constant", + alpha_kwargs={"alpha": -1.1}, + ) + + result = lf.phi(-20.0, z=0.5) + + assert np.asarray(result).shape == () + assert np.isfinite(result) + + +def test_parameters_raises_for_non_evolving_model(): + """Tests that parameters raises for non-evolving models.""" + lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, alpha=-1.1, - beta=-1.5, - m_transition=-19.5, ) - result = lf.phi(-20.0) + with pytest.raises(ValueError, match="only defined for evolving_schechter"): + lf.parameters(0.5) + + +def test_parameters_evaluates_evolving_schechter_parameters(): + """Tests that parameters evaluates evolving Schechter parameter values.""" + lf = LuminosityFunction.evolving_schechter( + phi_model="constant", + phi_kwargs={"phi_star": 1.0e-3}, + m_star_model="constant", + m_star_kwargs={"m_star": -20.5}, + alpha_model="constant", + alpha_kwargs={"alpha": -1.1}, + ) + + phi_star, m_star, alpha = lf.parameters(0.5) + + assert np.asarray(phi_star).shape == () + assert np.asarray(m_star).shape == () + assert np.asarray(alpha).shape == () + assert np.isfinite(phi_star) + assert np.isfinite(m_star) + assert np.isfinite(alpha) + + +def test_phi_from_m_evaluates_supported_model(): + """Tests that phi_from_m evaluates a supported model.""" + lf = LuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + ) + cosmo = make_test_cosmology() + + result = lf.phi_from_m( + cosmo, + 0.5, + 24.0, + h=0.7, + ) assert np.asarray(result).shape == () assert np.isfinite(result) -def test_phi_from_m_evaluates_supported_model(): +def test_phi_from_m_accepts_corrections_object(): + """Tests that phi_from_m accepts correction objects.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, @@ -177,31 +357,98 @@ def test_phi_from_m_evaluates_supported_model(): 0.5, 24.0, h=0.7, + corrections=DummyCorrections(), ) assert np.asarray(result).shape == () assert np.isfinite(result) -def test_phi_requires_redshift_for_evolving_model(): - lf = LuminosityFunction.evolving_schechter() +def test_correction_values_return_none_without_corrections(): + """Tests that correction values return None without corrections.""" + k_corr, e_corr = LuminosityFunction._correction_values(None, 0.5) - with pytest.raises(ValueError, match="z is required"): - lf.phi(-20.0) + assert k_corr is None + assert e_corr is None -def test_parameters_raises_for_non_evolving_model(): +def test_correction_values_evaluate_corrections_object(): + """Tests that correction values evaluate correction objects.""" + k_corr, e_corr = LuminosityFunction._correction_values( + DummyCorrections(), + np.array([0.3, 0.5]), + ) + + assert np.allclose(k_corr, [0.1, 0.1]) + assert np.allclose(e_corr, [0.2, 0.2]) + + +def test_with_luminosity_cutoff_preserves_model_and_merges_meta(): + """Tests that luminosity cutoffs preserve model names and merge metadata.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, alpha=-1.1, + meta={"source": "base"}, ) - with pytest.raises(ValueError, match="only defined for evolving_schechter"): - lf.parameters(0.5) + modified = lf.with_luminosity_cutoff(meta={"cutoff": True}) + + assert modified.model == "schechter" + assert modified.parameters_dict == lf.parameters_dict + assert modified.meta == {"source": "base", "cutoff": True} + + +def test_with_luminosity_cutoff_suppresses_phi_values(): + """Tests that luminosity cutoffs suppress phi values.""" + lf = LuminosityFunction.schechter( + phi_star=1.0e-3, + m_star=-20.5, + alpha=-1.1, + ) + modified = lf.with_luminosity_cutoff( + cutoff_power=2.0, + cutoff_amplitude=1.0, + ) + + base_phi = lf.phi(-22.0) + modified_phi = modified.phi(-22.0) + + assert np.asarray(modified_phi).shape == () + assert np.isfinite(modified_phi) + assert modified_phi < base_phi + + +def test_with_luminosity_cutoff_accepts_explicit_m_star(): + """Tests that luminosity cutoffs accept explicit m_star values.""" + lf = LuminosityFunction.gaussian( + mean_absolute_mag=-20.5, + sigma_absolute_mag=0.5, + amplitude=1.0, + ) + + modified = lf.with_luminosity_cutoff(m_star=-20.5) + + result = modified.phi(-20.0) + + assert np.asarray(result).shape == () + assert np.isfinite(result) + + +def test_with_luminosity_cutoff_requires_m_star_when_missing(): + """Tests that luminosity cutoffs require m_star when unavailable.""" + lf = LuminosityFunction.gaussian( + mean_absolute_mag=-20.5, + sigma_absolute_mag=0.5, + amplitude=1.0, + ) + + with pytest.raises(ValueError, match="m_star must be supplied"): + lf.with_luminosity_cutoff() def test_integrals_namespace_delegates_to_bound_lf_callable(): + """Tests that integrals delegate to the bound LF callable.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, @@ -220,6 +467,7 @@ def test_integrals_namespace_delegates_to_bound_lf_callable(): def test_completeness_namespace_delegates_to_bound_lf_callable(): + """Tests that completeness delegates to the bound LF callable.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, @@ -243,6 +491,7 @@ def test_completeness_namespace_delegates_to_bound_lf_callable(): def test_completeness_absolute_magnitude_limit_is_static_helper(): + """Tests that completeness exposes absolute magnitude limits.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, @@ -262,6 +511,7 @@ def test_completeness_absolute_magnitude_limit_is_static_helper(): def test_magnitude_namespace_static_helpers_are_available(): + """Tests that magnitude namespace static helpers are available.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, @@ -278,6 +528,7 @@ def test_magnitude_namespace_static_helpers_are_available(): def test_luminosity_namespace_static_helpers_are_available(): + """Tests that luminosity namespace static helpers are available.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, @@ -291,6 +542,7 @@ def test_luminosity_namespace_static_helpers_are_available(): def test_unsupported_model_raises_clear_error(): + """Tests that unknown LF models raise clear errors.""" lf = LuminosityFunction( model="not_a_model", parameters={}, @@ -301,6 +553,7 @@ def test_unsupported_model_raises_clear_error(): def test_unsupported_phi_from_m_model_raises_clear_error(): + """Tests that unsupported phi_from_m models raise clear errors.""" lf = LuminosityFunction( model="not_a_model", parameters={}, @@ -312,12 +565,24 @@ def test_unsupported_phi_from_m_model_raises_clear_error(): def test_available_model_helpers_return_public_model_names(): + """Tests that available model helpers return public model names.""" assert "schechter" in LuminosityFunction.available_models() assert "evolving_schechter" in LuminosityFunction.available_models() assert "schechter" in LuminosityFunction.available_from_m_models() +def test_available_model_helpers_return_sorted_names(): + """Tests that available model helpers return sorted names.""" + assert LuminosityFunction.available_models() == sorted( + LuminosityFunction.available_models() + ) + assert LuminosityFunction.available_from_m_models() == sorted( + LuminosityFunction.available_from_m_models() + ) + + def test_available_parameter_models_returns_grouped_registry_names(): + """Tests that parameter model helpers return grouped registry names.""" models = LuminosityFunction.available_parameter_models() assert "phi_star" in models @@ -326,13 +591,14 @@ def test_available_parameter_models_returns_grouped_registry_names(): def test_integrals_namespace_exposes_expected_methods(): + """Tests that integrals namespace exposes expected methods.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, alpha=-1.1, ) - expected = [ + expected_methods = [ "number_density", "weighted", "selection_weighted_number_density", @@ -340,36 +606,41 @@ def test_integrals_namespace_exposes_expected_methods(): "mean_luminosity", "cumulative_number_density", "magnitude_window_number_density", + "selection_fraction", + "selection_function", + "luminosity_weight", ] - for name in expected: + for name in expected_methods: assert callable(getattr(lf.integrals, name)) def test_redshift_density_namespace_exposes_expected_methods(): + """Tests that redshift density namespace exposes expected methods.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, alpha=-1.1, ) - expected = [ + expected_methods = [ "integrated_number_density", "weighted", ] - for name in expected: + for name in expected_methods: assert callable(getattr(lf.redshift_density, name)) def test_completeness_namespace_exposes_expected_methods(): + """Tests that completeness namespace exposes expected methods.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, alpha=-1.1, ) - expected = [ + expected_methods = [ "observed_number_density", "missing_number_density", "catalog_fraction", @@ -377,18 +648,19 @@ def test_completeness_namespace_exposes_expected_methods(): "absolute_magnitude_limit", ] - for name in expected: + for name in expected_methods: assert callable(getattr(lf.completeness, name)) def test_magnitudes_namespace_exposes_expected_methods(): + """Tests that magnitudes namespace exposes expected methods.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, alpha=-1.1, ) - expected = [ + expected_methods = [ "correction", "absolute", "absolute_from_luminosity_distance", @@ -396,11 +668,12 @@ def test_magnitudes_namespace_exposes_expected_methods(): "apparent_from_luminosity_distance", ] - for name in expected: + for name in expected_methods: assert callable(getattr(lf.magnitudes, name)) def test_luminosities_namespace_exposes_expected_methods(): + """Tests that luminosities namespace exposes expected methods.""" lf = LuminosityFunction.schechter( phi_star=1.0e-3, m_star=-20.5, @@ -419,31 +692,18 @@ def test_luminosities_namespace_exposes_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, - ) +def _minimal_parameter_payload(model_name, function): + """Return a minimal parameter payload for a registered model.""" + if model_name == "evolving_schechter": + return { + "phi_model": "constant", + "phi_kwargs": {"phi_star": 1.0e-3}, + "m_star_model": "constant", + "m_star_kwargs": {"m_star": -20.5}, + "alpha_model": "constant", + "alpha_kwargs": {"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(): @@ -453,20 +713,36 @@ def _minimal_parameter_payload(function): if parameter.default is not inspect.Parameter.empty: continue - if "phi" in name: + if name in {"phi_star", "modified_phi_star"}: payload[name] = 1.0e-3 - elif "m_star" in name or "mag" in name: + elif name == "log_phi_star": + payload[name] = -3.0 + elif name in { + "m_star", + "modified_m_star", + "mean_absolute_mag", + "lognormal_mean_absolute_mag", + "m_transition", + }: payload[name] = -20.5 - elif name in {"alpha", "beta"}: + elif name in { + "alpha", + "beta", + "alpha_faint", + "alpha_bright", + "modified_alpha", + }: payload[name] = -1.1 - elif "sigma" in name: + elif name in { + "sigma_absolute_mag", + "sigma_log_luminosity", + "lognormal_sigma_log_luminosity", + }: payload[name] = 0.2 - elif "amplitude" in name: + elif name in {"amplitude", "lognormal_amplitude"}: payload[name] = 1.0 - elif "fraction" in name: + elif name in {"fraction", "modified_luminosity_fraction"}: payload[name] = 0.5 - elif name == "m_transition": - payload[name] = -19.5 else: pytest.skip(f"No test default for required parameter {name!r}") @@ -474,11 +750,34 @@ def _minimal_parameter_payload(function): @pytest.mark.parametrize("model_name", sorted(LF_MODELS)) -def test_registered_luminosity_function_models_expose_generic_integrals(model_name): +def test_registered_luminosity_function_models_expose_constructors(model_name): + """Tests that registered models expose public API constructors.""" + assert callable(getattr(LuminosityFunction, model_name)) + + +@pytest.mark.parametrize("model_name", sorted(LF_MODELS)) +def test_registered_luminosity_function_models_evaluate_phi(model_name): + """Tests that registered models evaluate phi through the API.""" model_spec = LF_MODELS[model_name] + lf = getattr(LuminosityFunction, model_name)( + **_minimal_parameter_payload(model_name, model_spec.function) + ) + + if model_spec.requires_z: + result = lf.phi(-20.0, z=0.5) + else: + result = lf.phi(-20.0) + assert np.asarray(result).shape == () + assert np.isfinite(result) + + +@pytest.mark.parametrize("model_name", sorted(LF_MODELS)) +def test_registered_luminosity_function_models_expose_generic_integrals(model_name): + """Tests that registered models expose generic integral methods.""" + model_spec = LF_MODELS[model_name] lf = getattr(LuminosityFunction, model_name)( - **_minimal_parameter_payload(model_spec.function) + **_minimal_parameter_payload(model_name, model_spec.function) ) expected_methods = [ diff --git a/tests/test_api_namespaces.py b/tests/test_api_namespaces.py new file mode 100644 index 00000000..6cd2ebd4 --- /dev/null +++ b/tests/test_api_namespaces.py @@ -0,0 +1,282 @@ +"""Unit tests for ``api._namespaces``.""" + +from __future__ import annotations + +import types + +import numpy as np + +from lfkit.api._namespaces import ( + LFCompletenessAPI, + LFIntegralsAPI, + LFLuminositiesAPI, + LFMagnitudesAPI, + LFRedshiftDensityAPI, + _method_name, + _public_functions, + expose_lf_function, +) + + +class DummyLF: + """Minimal luminosity function object for namespace tests.""" + + def _as_callable(self): + """Return a simple luminosity function callable.""" + return lambda absolute_mag, redshift=None: np.asarray(absolute_mag) + 1.0 + + +class DummyBoundAPI: + """Minimal bound API object for injection tests.""" + + def __init__(self): + """Create a dummy API object.""" + self.lf = DummyLF() + + +def test_integrals_namespace_stores_parent_lf(): + """Tests that integrals namespace stores the parent LF.""" + lf = DummyLF() + api = LFIntegralsAPI(lf) + + assert api.lf is lf + + +def test_completeness_namespace_stores_parent_lf(): + """Tests that completeness namespace stores the parent LF.""" + lf = DummyLF() + api = LFCompletenessAPI(lf) + + assert api.lf is lf + + +def test_redshift_density_namespace_stores_parent_lf(): + """Tests that redshift density namespace stores the parent LF.""" + lf = DummyLF() + api = LFRedshiftDensityAPI(lf) + + assert api.lf is lf + + +def test_magnitude_namespace_can_be_instantiated(): + """Tests that magnitude namespace can be instantiated.""" + api = LFMagnitudesAPI() + + assert isinstance(api, LFMagnitudesAPI) + + +def test_luminosity_namespace_can_be_instantiated(): + """Tests that luminosity namespace can be instantiated.""" + api = LFLuminositiesAPI() + + assert isinstance(api, LFLuminositiesAPI) + + +def test_expose_lf_function_injects_lf_callable_by_position(): + """Tests that exposed functions inject LF callables by position.""" + calls = {} + + def low_level(x, lf_callable, z, *, scale=1.0): + calls["x"] = x + calls["z"] = z + calls["scale"] = scale + calls["lf_value"] = lf_callable(x, z) + return scale * calls["lf_value"] + + method = expose_lf_function(low_level, lf_arg_position=1) + api = DummyBoundAPI() + + result = method(api, 2.0, 0.5, scale=3.0) + + assert result == 9.0 + assert calls["x"] == 2.0 + assert calls["z"] == 0.5 + assert calls["scale"] == 3.0 + assert calls["lf_value"] == 3.0 + + +def test_expose_lf_function_injects_lf_callable_by_keyword(): + """Tests that exposed functions inject LF callables by keyword.""" + calls = {} + + def low_level(x, z, *, lf): + calls["lf_value"] = lf(x, z) + return calls["lf_value"] + + method = expose_lf_function( + low_level, + lf_arg_position=None, + lf_arg_name="lf", + ) + api = DummyBoundAPI() + + result = method(api, 2.0, 0.5) + + assert result == 3.0 + assert calls["lf_value"] == 3.0 + + +def test_expose_lf_function_uses_keyword_over_position(): + """Tests that keyword LF injection takes priority over position.""" + calls = {} + + def low_level(x, z, *, lf): + calls["x"] = x + calls["z"] = z + calls["lf_value"] = lf(x, z) + return calls["lf_value"] + + method = expose_lf_function( + low_level, + lf_arg_position=1, + lf_arg_name="lf", + ) + api = DummyBoundAPI() + + result = method(api, 2.0, 0.5) + + assert result == 3.0 + assert calls["x"] == 2.0 + assert calls["z"] == 0.5 + assert calls["lf_value"] == 3.0 + + +def test_expose_lf_function_without_lf_argument_delegates_directly(): + """Tests that exposed functions can delegate without LF injection.""" + + def low_level(x, *, scale=1.0): + return scale * x + + method = expose_lf_function(low_level, lf_arg_position=None) + api = DummyBoundAPI() + + result = method(api, 2.0, scale=4.0) + + assert result == 8.0 + + +def test_expose_lf_function_preserves_function_metadata(): + """Tests that exposed methods preserve wrapped function metadata.""" + + def low_level(x): + """Low-level test function.""" + return x + + method = expose_lf_function(low_level, lf_arg_position=None) + + assert method.__name__ == "low_level" + assert method.__doc__ == "Low-level test function." + + +def test_public_functions_returns_only_callables_from_all(): + """Tests that public function discovery returns callable __all__ members.""" + + def public_function(): + return 1 + + module = types.SimpleNamespace( + __all__=["public_function", "public_value"], + public_function=public_function, + public_value=3, + ) + + functions = _public_functions(module) + + assert functions == {"public_function": public_function} + + +def test_public_functions_returns_empty_dict_without_all(): + """Tests that public function discovery returns empty output without __all__.""" + module = types.SimpleNamespace(public_function=lambda: 1) + + functions = _public_functions(module) + + assert functions == {} + + +def test_method_name_uses_alias_when_available(): + """Tests that method names use module API aliases when available.""" + module = types.SimpleNamespace( + __api_aliases__={"low_level_name": "public_name"}, + ) + + assert _method_name(module, "low_level_name") == "public_name" + + +def test_method_name_falls_back_to_function_name_without_alias(): + """Tests that method names fall back to the low-level function name.""" + module = types.SimpleNamespace(__api_aliases__={}) + + assert _method_name(module, "low_level_name") == "low_level_name" + + +def test_integrals_namespace_has_bound_methods(): + """Tests that integrals namespace exposes bound methods.""" + 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(LFIntegralsAPI, name)) + + +def test_completeness_namespace_has_expected_methods(): + """Tests that completeness namespace exposes expected methods.""" + expected_methods = [ + "observed_number_density", + "missing_number_density", + "catalog_fraction", + "out_of_catalog_fraction", + "absolute_magnitude_limit", + ] + + for name in expected_methods: + assert callable(getattr(LFCompletenessAPI, name)) + + +def test_redshift_density_namespace_has_expected_methods(): + """Tests that redshift density namespace exposes expected methods.""" + expected_methods = [ + "integrated_number_density", + "weighted", + ] + + for name in expected_methods: + assert callable(getattr(LFRedshiftDensityAPI, name)) + + +def test_magnitude_namespace_has_expected_methods(): + """Tests that magnitude namespace exposes expected methods.""" + expected_methods = [ + "correction", + "absolute", + "absolute_from_luminosity_distance", + "apparent", + "apparent_from_luminosity_distance", + ] + + for name in expected_methods: + assert callable(getattr(LFMagnitudesAPI, name)) + + +def test_luminosity_namespace_has_expected_methods(): + """Tests that luminosity namespace exposes expected methods.""" + expected_methods = [ + "ratio", + "ratio_from_magnitudes", + "magnitude_difference_from_ratio", + "weight_from_magnitude", + "from_magnitude", + ] + + for name in expected_methods: + assert callable(getattr(LFLuminositiesAPI, name)) diff --git a/tests/test_corrections_color_anchors.py b/tests/test_corrections_color_anchors.py index 77a9588f..5644e70e 100644 --- a/tests/test_corrections_color_anchors.py +++ b/tests/test_corrections_color_anchors.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.corrections.color_anchors.py``.""" +"""Unit tests for ``lfkit.corrections.color_anchors``.""" from __future__ import annotations diff --git a/tests/test_corrections_filters.py b/tests/test_corrections_filters.py index 487a3dfe..188f5e1d 100644 --- a/tests/test_corrections_filters.py +++ b/tests/test_corrections_filters.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.corrections.filters.py``.""" +"""Unit tests for ``lfkit.corrections.filters``.""" from __future__ import annotations diff --git a/tests/test_corrections_kcorrect_backend.py b/tests/test_corrections_kcorrect_backend.py index 1cae3d2f..5fad6da2 100644 --- a/tests/test_corrections_kcorrect_backend.py +++ b/tests/test_corrections_kcorrect_backend.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.corrections.kcorrect_backend.py``.""" +"""Unit tests for ``lfkit.corrections.kcorrect_backend``.""" from __future__ import annotations diff --git a/tests/test_corrections_kcorrect_from_color.py b/tests/test_corrections_kcorrect_from_color.py index 13a3ec5f..c1eeec15 100644 --- a/tests/test_corrections_kcorrect_from_color.py +++ b/tests/test_corrections_kcorrect_from_color.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.corrections.kcorrect_from_color.py``.""" +"""Unit tests for ``lfkit.corrections.kcorrect_from_color``.""" from __future__ import annotations diff --git a/tests/test_corrections_kcorrrect_grids.py b/tests/test_corrections_kcorrrect_grids.py index 3fe421b6..45d77dba 100644 --- a/tests/test_corrections_kcorrrect_grids.py +++ b/tests/test_corrections_kcorrrect_grids.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.corrections.kcorrect_grid.py``.""" +"""Unit tests for ``lfkit.corrections.kcorrect_grid``.""" from __future__ import annotations diff --git a/tests/test_corrections_poggianti1997.py b/tests/test_corrections_poggianti1997.py index a8877cb1..f8518026 100644 --- a/tests/test_corrections_poggianti1997.py +++ b/tests/test_corrections_poggianti1997.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.about.poggianti1997.py``.""" +"""Unit tests for ``lfkit.about.poggianti1997``.""" from __future__ import annotations diff --git a/tests/test_corrections_responses.py b/tests/test_corrections_responses.py index bf300021..80790ea0 100644 --- a/tests/test_corrections_responses.py +++ b/tests/test_corrections_responses.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.about.responses.py``.""" +"""Unit tests for ``lfkit.about.responses``.""" from __future__ import annotations diff --git a/tests/test_cosmo_cosmology.py b/tests/test_cosmo_cosmology.py index 348cd6ee..0c0d6ad8 100644 --- a/tests/test_cosmo_cosmology.py +++ b/tests/test_cosmo_cosmology.py @@ -1,30 +1,38 @@ -"""Unit tests for ``lfkit.cosmo.cosmology.py``.""" +"""Unit tests for ``lfkit.cosmo.cosmology``.""" from __future__ import annotations import numpy as np -import pytest import pyccl as ccl +import pytest -from lfkit.cosmo.cosmology import cosmo_object, lookback_time_gyr +from lfkit.cosmo.cosmology import ( + C_KM_S, + comoving_distance_mpc, + cosmo_object, + differential_comoving_volume, + distance_modulus, + lookback_time_gyr, + luminosity_distance_mpc, +) def test_cosmo_object_returns_instance_unchanged(): - """Tests that cosmo_object returns the provided instance unchanged when instance is given.""" + """Tests that cosmo_object returns the provided instance unchanged.""" inst = ccl.CosmologyVanillaLCDM() out = cosmo_object(instance=inst) assert out is inst def test_cosmo_object_raises_if_instance_and_params_given(): - """Tests that cosmo_object raises ValueError when both instance and params are provided.""" + """Tests that cosmo_object raises ValueError when instance and params are given.""" inst = ccl.CosmologyVanillaLCDM() with pytest.raises(ValueError): - cosmo_object(instance=inst, Omega_c=0.25) # any param triggers the error + cosmo_object(instance=inst, Omega_c=0.25) def test_cosmo_object_builds_from_params(): - """Tests that cosmo_object constructs a ccl.Cosmology when parameters are provided.""" + """Tests that cosmo_object constructs a ccl.Cosmology from parameters.""" cosmo = cosmo_object( Omega_c=0.25, Omega_b=0.05, @@ -36,13 +44,13 @@ def test_cosmo_object_builds_from_params(): def test_cosmo_object_defaults_to_vanilla_lcdm(): - """Tests that cosmo_object returns CosmologyVanillaLCDM when no instance or params are provided.""" + """Tests that cosmo_object returns a default ccl.Cosmology.""" cosmo = cosmo_object() assert isinstance(cosmo, ccl.Cosmology) def test_lookback_time_gyr_shape_and_dtype_scalar_and_array(): - """Tests that lookback_time_gyr returns float arrays with shapes matching scalar and vector z inputs.""" + """Tests that lookback_time_gyr returns float arrays matching input shapes.""" cosmo = ccl.CosmologyVanillaLCDM() t0 = lookback_time_gyr(cosmo, 0.0) @@ -60,7 +68,7 @@ def test_lookback_time_gyr_shape_and_dtype_scalar_and_array(): def test_lookback_time_gyr_monotonic_in_redshift(): - """Tests that lookback_time_gyr is non-decreasing with increasing redshift.""" + """Tests that lookback_time_gyr is non-decreasing with redshift.""" cosmo = ccl.CosmologyVanillaLCDM() z = np.array([0.0, 0.2, 0.5, 1.0, 2.0]) t = lookback_time_gyr(cosmo, z) @@ -68,7 +76,107 @@ def test_lookback_time_gyr_monotonic_in_redshift(): def test_lookback_time_gyr_zero_at_z0_with_tolerance(): - """Tests that lookback_time_gyr at z=0 is approximately zero within numerical tolerance.""" + """Tests that lookback_time_gyr is approximately zero at z=0.""" cosmo = ccl.CosmologyVanillaLCDM() t0 = float(lookback_time_gyr(cosmo, 0.0)) assert abs(t0) < 1e-10 + + +def test_speed_of_light_constant(): + """Tests that C_KM_S stores the expected speed of light in km/s.""" + assert C_KM_S == pytest.approx(299792.458) + + +def test_luminosity_distance_shape_dtype_and_z0(): + """Tests that luminosity_distance_mpc returns floats and zero distance at z=0.""" + cosmo = ccl.CosmologyVanillaLCDM() + + d0 = luminosity_distance_mpc(cosmo, 0.0) + assert isinstance(d0, np.ndarray) + assert d0.shape == () + assert d0.dtype == float + assert d0 == pytest.approx(0.0) + + z = np.array([0.1, 0.5, 1.0]) + d = luminosity_distance_mpc(cosmo, z) + assert d.shape == z.shape + assert d.dtype == float + assert np.all(np.isfinite(d)) + assert np.all(d > 0.0) + assert np.all(d[1:] > d[:-1]) + + +def test_comoving_distance_shape_dtype_z0_and_monotonic(): + """Tests that comoving_distance_mpc returns floats starting at zero.""" + cosmo = ccl.CosmologyVanillaLCDM() + z = np.array([0.0, 0.1, 0.5, 1.0]) + + chi = comoving_distance_mpc(cosmo, z) + + assert isinstance(chi, np.ndarray) + assert chi.shape == z.shape + assert chi.dtype == float + assert chi[0] == pytest.approx(0.0) + assert np.all(np.isfinite(chi)) + assert np.all(chi[1:] > chi[:-1]) + + +def test_comoving_distance_matches_ccl_comoving_radial_distance_approximately(): + """Tests that comoving_distance_mpc approximately matches PyCCL distances.""" + cosmo = ccl.CosmologyVanillaLCDM() + z = np.linspace(0.0, 2.0, 512) + a = 1.0 / (1.0 + z) + + chi = comoving_distance_mpc(cosmo, z) + expected = np.asarray(ccl.comoving_radial_distance(cosmo, a), dtype=float) + + assert np.allclose(chi, expected, rtol=5e-3, atol=1e-6) + + +def test_distance_modulus_matches_luminosity_distance_formula(): + """Tests that distance_modulus applies the luminosity-distance formula.""" + cosmo = ccl.CosmologyVanillaLCDM() + z = np.array([0.1, 0.5, 1.0]) + + d_l = luminosity_distance_mpc(cosmo, z) + mu = distance_modulus(cosmo, z) + + assert np.allclose(mu, 5.0 * np.log10(d_l) + 25.0) + + +def test_distance_modulus_with_h_rescaling(): + """Tests that distance_modulus applies the optional h rescaling convention.""" + cosmo = ccl.CosmologyVanillaLCDM() + z = np.array([0.1, 0.5, 1.0]) + h = 0.7 + + d_l = luminosity_distance_mpc(cosmo, z) + mu = distance_modulus(cosmo, z, h=h) + + assert np.allclose(mu, 5.0 * np.log10(d_l * h) + 25.0) + + +def test_differential_comoving_volume_shape_dtype_and_nonnegative(): + """Tests that differential_comoving_volume returns finite non-negative floats.""" + cosmo = ccl.CosmologyVanillaLCDM() + z = np.array([0.0, 0.1, 0.5, 1.0]) + + dv_dz = differential_comoving_volume(cosmo, z) + + assert isinstance(dv_dz, np.ndarray) + assert dv_dz.shape == z.shape + assert dv_dz.dtype == float + assert np.all(np.isfinite(dv_dz)) + assert dv_dz[0] == pytest.approx(0.0) + assert np.all(dv_dz >= 0.0) + + +def test_differential_comoving_volume_scales_with_frac_sky(): + """Tests that differential_comoving_volume scales linearly with sky fraction.""" + cosmo = ccl.CosmologyVanillaLCDM() + z = np.array([0.1, 0.5, 1.0]) + + full_sky = differential_comoving_volume(cosmo, z, frac_sky=1.0) + half_sky = differential_comoving_volume(cosmo, z, frac_sky=0.5) + + assert np.allclose(half_sky, 0.5 * full_sky) diff --git a/tests/test_lumfuncs_completeness.py b/tests/test_lumfuncs_completeness.py index 27805232..81c4d099 100644 --- a/tests/test_lumfuncs_completeness.py +++ b/tests/test_lumfuncs_completeness.py @@ -1,4 +1,4 @@ -"""Unit tests for the ``lfkit.photometry.catalog_completeness.py`` module.""" +"""Unit tests for the ``lfkit.photometry.catalog_completeness``.""" import numpy as np import pytest @@ -530,3 +530,14 @@ def test_absolute_magnitude_limit_rejects_missing_h() -> None: match="h was not provided and could not be read from cosmo_obj", ): cc.absolute_magnitude_limit(object(), [0.1, 0.2], m_lim=24.5) + + +def test_absolute_magnitude_limit_rejects_nonfinite_h() -> None: + """Tests that non-finite explicit h is rejected.""" + with pytest.raises(ValueError, match="h must be finite"): + cc.absolute_magnitude_limit( + object(), + [0.1, 0.2], + m_lim=24.5, + h=np.inf, + ) diff --git a/tests/test_lumfuncs_completeness_fake_catalog.py b/tests/test_lumfuncs_completeness_fake_catalog.py index 665f0173..71e1339f 100644 --- a/tests/test_lumfuncs_completeness_fake_catalog.py +++ b/tests/test_lumfuncs_completeness_fake_catalog.py @@ -253,3 +253,46 @@ def test_observed_and_missing_number_densities_sum_to_total_density() -> None: rtol=1.0e-6, atol=1.0e-12, ) + + +def test_fake_catalog_redshifts_are_physical() -> None: + """Tests that fake catalog redshifts are finite and non-negative.""" + catalog = load_fake_catalog() + z = np.asarray(catalog["z"], dtype=float) + + assert np.all(np.isfinite(z)) + assert np.all(z >= 0.0) + + +def test_fake_catalog_apparent_magnitudes_are_finite() -> None: + """Tests that fake catalog apparent magnitudes are finite.""" + catalog = load_fake_catalog() + m_app = np.asarray(catalog["m_app"], dtype=float) + + assert np.all(np.isfinite(m_app)) + + +def test_catalog_fraction_accepts_k_and_e_corrections() -> None: + """Tests fake-catalog completeness with k/e corrections.""" + catalog = load_fake_catalog() + cosmo = make_cosmology() + z = np.asarray(catalog["z"], dtype=float) + + completeness = catalog_fraction( + cosmo, + z, + toy_luminosity_function, + m_lim=24.0, + m_bright=-24.0, + m_faint=-14.0, + n_m=256, + h=0.7, + k_correction=0.1 * z, + e_correction=0.05 * z, + ) + + assert completeness.shape == z.shape + assert np.all(np.isfinite(completeness)) + assert np.all((completeness >= 0.0) & (completeness <= 1.0)) + + diff --git a/tests/test_lumfuncs_conditional_integrals.py b/tests/test_lumfuncs_conditional_integrals.py index 0ea963e8..48442114 100644 --- a/tests/test_lumfuncs_conditional_integrals.py +++ b/tests/test_lumfuncs_conditional_integrals.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.photometry.conditional_lf_integrals.py``.""" +"""Unit tests for ``lfkit.photometry.conditional_lf_integrals``.""" import numpy as np import pytest @@ -348,3 +348,90 @@ def weight(absolute_mag, condition): conditional_lf=conditional_lf, weight=weight, ) + + +def test_evaluate_conditional_lf_allows_zero_values() -> None: + """Tests that zero conditional LF values are allowed.""" + + def conditional_lf(absolute_mag, condition): + return np.zeros_like(absolute_mag, dtype=float) + + result = evaluate_conditional_luminosity_function( + absolute_mag=[-22.0, -21.0, -20.0], + condition=1.0, + conditional_lf=conditional_lf, + ) + + np.testing.assert_allclose(result, np.zeros(3)) + + +def test_evaluate_conditional_lf_rejects_non_finite_broadcasted_condition() -> None: + """Tests that non-finite broadcasted condition arrays are rejected.""" + + def conditional_lf(absolute_mag, condition): + return np.ones((2, 3)) + + with pytest.raises(ValueError, match="condition contains NaN or infinite values."): + evaluate_conditional_luminosity_function( + absolute_mag=np.array([-22.0, -21.0, -20.0]), + condition=np.array([[1.0], [np.nan]]), + conditional_lf=conditional_lf, + ) + + +def test_integrate_conditional_lf_matches_constant_function_width() -> None: + """Tests integration of a constant conditional LF over magnitude width.""" + + absolute_mag = np.array([-23.0, -22.0, -21.0, -20.0]) + + def conditional_lf(absolute_mag, condition): + return 2.0 * np.ones_like(absolute_mag) + + result = integrate_conditional_luminosity_function( + absolute_mag=absolute_mag, + condition=1.0, + conditional_lf=conditional_lf, + ) + + assert result == pytest.approx(6.0) + + +def test_integrate_weighted_conditional_lf_accepts_scalar_weight() -> None: + """Tests that scalar weights broadcast over the conditional LF.""" + + absolute_mag = np.array([-22.0, -21.0, -20.0]) + + def conditional_lf(absolute_mag, condition): + return np.ones_like(absolute_mag) + + def weight(absolute_mag, condition): + return 2.0 + + result = integrate_weighted_conditional_luminosity_function( + absolute_mag=absolute_mag, + condition=1.0, + conditional_lf=conditional_lf, + weight=weight, + ) + + expected = np.trapezoid(2.0 * np.ones_like(absolute_mag), x=absolute_mag) + + assert result == pytest.approx(expected) + + +def test_integrate_weighted_conditional_lf_rejects_nan_weight() -> None: + """Tests that NaN weights are rejected.""" + + def conditional_lf(absolute_mag, condition): + return np.ones_like(absolute_mag) + + def weight(absolute_mag, condition): + return np.array([1.0, np.nan, 3.0]) + + with pytest.raises(ValueError, match="weight returned NaN or infinite values."): + integrate_weighted_conditional_luminosity_function( + absolute_mag=[-22.0, -21.0, -20.0], + condition=1.0, + conditional_lf=conditional_lf, + weight=weight, + ) diff --git a/tests/test_lumfuncs_conditional_models.py b/tests/test_lumfuncs_conditional_models.py index c22e1e52..a442cf68 100644 --- a/tests/test_lumfuncs_conditional_models.py +++ b/tests/test_lumfuncs_conditional_models.py @@ -1,8 +1,13 @@ -"""Unit tests for ``lfkit.photometry.conditional_lf_models.py``.""" +"""Unit tests for ``lfkit.photometry.conditional_lf_models``.""" import numpy as np import pytest + +from lfkit.luminosity_functions.conditional_models import ( + __all__, + conditionalize_lf_model, +) 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 @@ -382,3 +387,101 @@ def test_conditional_two_component_lf_propagates_invalid_modified_component() -> lognormal_amplitude=1.0, modified_m_star=-20.5, ) + + +def test_conditionalize_lf_model_preserves_wrapped_model_name() -> None: + """Tests that conditional wrappers preserve model metadata.""" + + def toy_lf(absolute_mag, amplitude): + return amplitude * np.ones_like(absolute_mag, dtype=float) + + conditional_toy_lf = conditionalize_lf_model(toy_lf) + + assert conditional_toy_lf.__name__ == "toy_lf" + + +def test_conditionalize_lf_model_passes_non_callable_kwargs_unchanged() -> None: + """Tests that non-callable keyword arguments pass through unchanged.""" + + absolute_mag = np.array([-22.0, -21.0, -20.0]) + + def toy_lf(absolute_mag, amplitude, offset): + return amplitude * np.ones_like(absolute_mag, dtype=float) + offset + + conditional_toy_lf = conditionalize_lf_model(toy_lf) + + result = conditional_toy_lf( + absolute_mag=absolute_mag, + condition=np.array([0.0, 1.0, 2.0]), + amplitude=2.0, + offset=3.0, + ) + + np.testing.assert_allclose(result, np.array([5.0, 5.0, 5.0])) + assert result.dtype == np.float64 + + +def test_conditionalize_lf_model_rejects_negative_wrapped_output() -> None: + """Tests that negative wrapped LF outputs are rejected.""" + + def toy_lf(absolute_mag, amplitude): + return np.array([1.0, -1.0, 2.0]) + + conditional_toy_lf = conditionalize_lf_model(toy_lf) + + with pytest.raises( + ValueError, + match="toy_lf returned negative values, which are not allowed.", + ): + conditional_toy_lf( + absolute_mag=[-22.0, -21.0, -20.0], + condition=[0.0, 1.0, 2.0], + amplitude=1.0, + ) + + +def test_conditionalize_lf_model_rejects_non_finite_wrapped_output() -> None: + """Tests that non-finite wrapped LF outputs are rejected.""" + + def toy_lf(absolute_mag, amplitude): + return np.array([1.0, np.inf, 2.0]) + + conditional_toy_lf = conditionalize_lf_model(toy_lf) + + with pytest.raises(ValueError, match="toy_lf contains NaN or infinite values."): + conditional_toy_lf( + absolute_mag=[-22.0, -21.0, -20.0], + condition=[0.0, 1.0, 2.0], + amplitude=1.0, + ) + + +def test_conditional_model_registry_exports_generated_names() -> None: + """Tests that generated conditional model names are public exports.""" + + assert "conditional_schechter" in __all__ + assert "conditional_double_schechter" in __all__ + assert "conditional_lognormal_lf" in __all__ + assert "conditional_two_component_lf" in __all__ + + +def test_conditional_schechter_accepts_scalar_condition_with_callable_parameter() -> None: + """Tests callable parameter evaluation for scalar condition input.""" + + result = conditional_schechter( + absolute_mag=np.array([-22.0, -21.0, -20.0]), + condition=2.0, + phi_star=lambda x: 1.0e-3 * (1.0 + x), + m_star=-21.0, + alpha=-1.1, + ) + + expected = schechter( + np.array([-22.0, -21.0, -20.0]), + phi_star=3.0e-3, + m_star=-21.0, + alpha=-1.1, + ) + + np.testing.assert_allclose(result, expected) + assert result.dtype == np.float64 diff --git a/tests/test_lumfuncs_discovery.py b/tests/test_lumfuncs_discovery.py new file mode 100644 index 00000000..bb5a7e7b --- /dev/null +++ b/tests/test_lumfuncs_discovery.py @@ -0,0 +1,220 @@ +"""Unit tests for ``lfkit.luminosity_functions._discovery``.""" + +from __future__ import annotations + +from collections.abc import Iterator +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from lfkit.luminosity_functions import _discovery + + +def _module_info(name: str) -> SimpleNamespace: + """Return a small stand-in for ``pkgutil.ModuleInfo``.""" + return SimpleNamespace(name=name) + + +def test_iter_model_functions_discovers_callables_from_public_modules( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests that callable names in ``__all__`` are discovered.""" + + def model_function() -> None: + return None + + fake_module = SimpleNamespace( + __all__=["model_function"], + model_function=model_function, + ) + + monkeypatch.setattr( + _discovery.pkgutil, + "iter_modules", + lambda path: [_module_info("fake_models")], + ) + monkeypatch.setattr( + _discovery.importlib, + "import_module", + lambda name: fake_module, + ) + + result = _discovery.iter_model_functions() + + assert result == {"model_function": model_function} + + +def test_iter_model_functions_ignores_non_callables_in_all( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests that exported non-callable objects are ignored.""" + + def model_function() -> None: + return None + + fake_module = SimpleNamespace( + __all__=["model_function", "CONSTANT"], + model_function=model_function, + CONSTANT=1.0, + ) + + monkeypatch.setattr( + _discovery.pkgutil, + "iter_modules", + lambda path: [_module_info("fake_models")], + ) + monkeypatch.setattr( + _discovery.importlib, + "import_module", + lambda name: fake_module, + ) + + result = _discovery.iter_model_functions() + + assert result == {"model_function": model_function} + + +def test_iter_model_functions_ignores_modules_without_all( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests that modules without ``__all__`` contribute no functions.""" + + def hidden_model() -> None: + return None + + fake_module = SimpleNamespace(hidden_model=hidden_model) + + monkeypatch.setattr( + _discovery.pkgutil, + "iter_modules", + lambda path: [_module_info("fake_models")], + ) + monkeypatch.setattr( + _discovery.importlib, + "import_module", + lambda name: fake_module, + ) + + result = _discovery.iter_model_functions() + + assert result == {} + + +def test_iter_model_functions_skips_private_modules( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests that modules beginning with an underscore are skipped.""" + + import_module = Mock() + + monkeypatch.setattr( + _discovery.pkgutil, + "iter_modules", + lambda path: [_module_info("_private_models")], + ) + monkeypatch.setattr(_discovery.importlib, "import_module", import_module) + + result = _discovery.iter_model_functions() + + assert result == {} + import_module.assert_not_called() + + +def test_iter_model_functions_skips_modifier_modules( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests that explicitly skipped modules are not imported.""" + + import_module = Mock() + + monkeypatch.setattr( + _discovery.pkgutil, + "iter_modules", + lambda path: [_module_info("modifiers")], + ) + monkeypatch.setattr(_discovery.importlib, "import_module", import_module) + + result = _discovery.iter_model_functions() + + assert result == {} + import_module.assert_not_called() + + +def test_iter_model_functions_imports_expected_module_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests that discovered modules are imported from the models package.""" + + fake_module = SimpleNamespace(__all__=[]) + import_module = Mock(return_value=fake_module) + + monkeypatch.setattr( + _discovery.pkgutil, + "iter_modules", + lambda path: [_module_info("schechter")], + ) + monkeypatch.setattr(_discovery.importlib, "import_module", import_module) + + _discovery.iter_model_functions() + + import_module.assert_called_once_with( + f"{_discovery.models_pkg.__name__}.schechter" + ) + + +def test_iter_model_functions_later_modules_override_duplicate_names( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests that later modules overwrite earlier duplicate exported names.""" + + def first_model() -> str: + return "first" + + def second_model() -> str: + return "second" + + modules = { + f"{_discovery.models_pkg.__name__}.first": SimpleNamespace( + __all__=["same_name"], + same_name=first_model, + ), + f"{_discovery.models_pkg.__name__}.second": SimpleNamespace( + __all__=["same_name"], + same_name=second_model, + ), + } + + monkeypatch.setattr( + _discovery.pkgutil, + "iter_modules", + lambda path: [_module_info("first"), _module_info("second")], + ) + monkeypatch.setattr( + _discovery.importlib, + "import_module", + lambda name: modules[name], + ) + + result = _discovery.iter_model_functions() + + assert result == {"same_name": second_model} + + +def test_iter_model_functions_uses_models_package_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests that module discovery uses ``models_pkg.__path__``.""" + + seen_paths = [] + + def iter_modules(path: object) -> Iterator[SimpleNamespace]: + seen_paths.append(path) + return iter(()) + + monkeypatch.setattr(_discovery.pkgutil, "iter_modules", iter_modules) + + result = _discovery.iter_model_functions() + + assert result == {} + assert seen_paths == [_discovery.models_pkg.__path__] diff --git a/tests/test_lumfuncs_integrals.py b/tests/test_lumfuncs_integrals.py index 388cf7d6..ed9127e3 100644 --- a/tests/test_lumfuncs_integrals.py +++ b/tests/test_lumfuncs_integrals.py @@ -1,4 +1,4 @@ -"""Unit tests for the ``lfkit.photometry.lf_integrals.py`` module.""" +"""Unit tests for the ``lfkit.photometry.lf_integrals``.""" import numpy as np import pytest @@ -814,3 +814,104 @@ 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() + + +def test_luminosity_weight_rejects_nonfinite_absolute_mag() -> None: + """Tests that luminosity weights reject non-finite magnitudes.""" + with pytest.raises(ValueError, match="absolute_mag contains NaN or infinite values"): + li.luminosity_weight([0.0, np.nan, 1.0]) + + +def test_luminosity_weight_clips_extreme_values() -> None: + """Tests that luminosity weights are clipped to finite numerical bounds.""" + result = li.luminosity_weight(np.array([-1000.0, 1000.0])) + + np.testing.assert_allclose(result, np.array([1.0e300, 1.0e-300])) + + +def test_lf_weighted_integral_accepts_scalar_weight_output() -> None: + """Tests that scalar weight outputs broadcast over the LF grid.""" + + def weight_fn(m_abs: np.ndarray, z: np.ndarray) -> float: + return 2.0 + + result = li.lf_weighted_integral( + [0.1, 0.2], + constant_lf, + m_bright=-24.0, + m_faint=-18.0, + weight_fn=weight_fn, + n_m=64, + ) + + np.testing.assert_allclose(result, np.array([12.0, 12.0])) + + +def test_selection_weighted_number_density_rejects_negative_selection() -> None: + """Tests that negative selection values are rejected.""" + + def selection_fn(m_abs: np.ndarray, z: np.ndarray) -> np.ndarray: + return -np.ones_like(m_abs, dtype=float) + + with pytest.raises(ValueError, match="weight_fn\\(M, z\\) must be non-negative"): + li.selection_weighted_number_density( + [0.1, 0.2], + constant_lf, + m_bright=-24.0, + m_faint=-18.0, + selection_fn=selection_fn, + ) + + +def test_magnitude_window_number_density_rejects_duplicate_faint_bounds() -> None: + """Tests that absolute and apparent faint bounds cannot both be supplied.""" + with pytest.raises( + ValueError, + match="Provide only one of m_faint or apparent_m_faint", + ): + li.magnitude_window_number_density( + 0.1, + constant_lf, + m_bright=-24.0, + m_faint=-18.0, + apparent_m_faint=22.0, + ) + + +def test_magnitude_window_density_rejects_nonpositive_luminosity_distance() -> None: + """Tests that apparent bounds reject non-positive luminosity distances.""" + + def luminosity_distance_mpc_fn(z: np.ndarray) -> np.ndarray: + return np.zeros_like(z, dtype=float) + + with pytest.raises( + ValueError, + match="luminosity_distance_mpc_fn\\(z\\) must return positive values", + ): + li.magnitude_window_number_density( + [0.1, 0.2], + constant_lf, + apparent_m_bright=18.0, + m_faint=-18.0, + luminosity_distance_mpc_fn=luminosity_distance_mpc_fn, + ) + + +def test_magnitude_window_density_rejects_nonfinite_k_correction() -> None: + """Tests that non-finite K-corrections are rejected.""" + + def luminosity_distance_mpc_fn(z: np.ndarray) -> np.ndarray: + return 10.0 * np.ones_like(z, dtype=float) + + def k_correction_fn(z: np.ndarray) -> np.ndarray: + return np.full_like(z, np.nan, dtype=float) + + with pytest.raises(ValueError, match="k_correction_fn\\(z\\) returned non-finite values"): + li.magnitude_window_number_density( + [0.1, 0.2], + constant_lf, + apparent_m_bright=18.0, + m_faint=-18.0, + luminosity_distance_mpc_fn=luminosity_distance_mpc_fn, + k_correction_fn=k_correction_fn, + ) diff --git a/tests/test_lumfuncs_models_composite.py b/tests/test_lumfuncs_models_composite.py new file mode 100644 index 00000000..98bb0338 --- /dev/null +++ b/tests/test_lumfuncs_models_composite.py @@ -0,0 +1,367 @@ +"""Unit tests for ``lfkit.luminosity_functions.models.composite``.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from lfkit.luminosity_functions.models.composite import ( + additive_lf, + two_component_lf, +) +from lfkit.luminosity_functions.models.gaussian import lognormal_lf +from lfkit.luminosity_functions.models.modifiers import apply_luminosity_cutoff +from lfkit.luminosity_functions.models.schechter import schechter +from lfkit.photometry.luminosities import magnitude_difference_from_luminosity_ratio + + +def test_additive_lf_sums_single_component() -> None: + """Tests that additive_lf returns a single component unchanged.""" + absolute_mag = np.array([-22.0, -21.0, -20.0]) + + result = additive_lf( + absolute_mag, + lambda mag: np.ones_like(mag), + ) + + np.testing.assert_allclose(result, np.ones_like(absolute_mag)) + + +def test_additive_lf_sums_multiple_components() -> None: + """Tests that additive_lf sums several component functions.""" + absolute_mag = np.array([-22.0, -21.0, -20.0]) + + result = additive_lf( + absolute_mag, + lambda mag: np.ones_like(mag), + lambda mag: 2.0 * np.ones_like(mag), + lambda mag: mag + 25.0, + ) + + expected = 3.0 + absolute_mag + 25.0 + np.testing.assert_allclose(result, expected) + + +def test_additive_lf_accepts_scalar_input() -> None: + """Tests that additive_lf accepts scalar magnitude input.""" + result = additive_lf( + -21.0, + lambda mag: np.ones_like(mag), + lambda mag: 2.0 * np.ones_like(mag), + ) + + assert np.shape(result) == () + np.testing.assert_allclose(result, np.array(3.0)) + + +def test_additive_lf_preserves_array_shape() -> None: + """Tests that additive_lf preserves the input magnitude shape.""" + absolute_mag = np.array([[-23.0, -22.0], [-21.0, -20.0]]) + + result = additive_lf( + absolute_mag, + lambda mag: np.ones_like(mag), + lambda mag: mag + 25.0, + ) + + assert result.shape == absolute_mag.shape + + +def test_additive_lf_returns_float_array() -> None: + """Tests that additive_lf returns a floating-point array.""" + result = additive_lf( + np.array([-22, -21, -20]), + lambda mag: np.ones_like(mag, dtype=int), + ) + + assert result.dtype.kind == "f" + + +def test_additive_lf_rejects_missing_components() -> None: + """Tests that additive_lf requires at least one component.""" + with pytest.raises( + ValueError, + match="At least one luminosity function component is required", + ): + additive_lf(np.array([-22.0, -21.0])) + + +def test_additive_lf_rejects_nonfinite_magnitudes() -> None: + """Tests that additive_lf rejects non-finite magnitude values.""" + with pytest.raises(ValueError, match="absolute_mag contains NaN or infinite values"): + additive_lf( + np.array([-22.0, np.nan]), + lambda mag: np.ones_like(mag), + ) + + +def test_additive_lf_supports_broadcastable_scalar_component() -> None: + """Tests that additive_lf supports scalar component outputs.""" + absolute_mag = np.array([-22.0, -21.0, -20.0]) + + result = additive_lf( + absolute_mag, + lambda mag: 1.0, + lambda mag: np.ones_like(mag), + ) + + np.testing.assert_allclose(result, 2.0 * np.ones_like(absolute_mag)) + + +def test_two_component_lf_matches_manual_component_sum_with_explicit_m_star() -> None: + """Tests that two_component_lf matches explicit lognormal plus modified Schechter sum.""" + absolute_mag = np.array([-23.0, -22.0, -21.0, -20.0]) + + params = { + "lognormal_mean_absolute_mag": -21.5, + "lognormal_sigma_log_luminosity": 0.25, + "lognormal_amplitude": 1.7, + "modified_phi_star": 0.004, + "modified_m_star": -20.8, + "modified_alpha": -1.1, + } + + result = two_component_lf(absolute_mag, **params) + + expected_lognormal = lognormal_lf( + absolute_mag, + mean_absolute_mag=params["lognormal_mean_absolute_mag"], + sigma_log_luminosity=params["lognormal_sigma_log_luminosity"], + amplitude=params["lognormal_amplitude"], + ) + expected_modified = apply_luminosity_cutoff( + absolute_mag, + base_lf=schechter, + phi_star=params["modified_phi_star"], + m_star=params["modified_m_star"], + alpha=params["modified_alpha"], + ) + expected = expected_lognormal + expected_modified + + np.testing.assert_allclose(result, expected) + + +def test_two_component_lf_infers_modified_m_star_from_luminosity_fraction() -> None: + """Tests that two_component_lf infers modified_m_star from the luminosity fraction.""" + absolute_mag = np.array([-23.0, -22.0, -21.0, -20.0]) + mean_mag = -21.5 + luminosity_fraction = 0.562 + + result = two_component_lf( + absolute_mag, + lognormal_mean_absolute_mag=mean_mag, + lognormal_sigma_log_luminosity=0.25, + lognormal_amplitude=1.7, + modified_phi_star=0.004, + modified_alpha=-1.1, + modified_luminosity_fraction=luminosity_fraction, + ) + + inferred_m_star = mean_mag + magnitude_difference_from_luminosity_ratio( + luminosity_fraction, + ) + expected = lognormal_lf( + absolute_mag, + mean_absolute_mag=mean_mag, + sigma_log_luminosity=0.25, + amplitude=1.7, + ) + apply_luminosity_cutoff( + absolute_mag, + base_lf=schechter, + phi_star=0.004, + m_star=inferred_m_star, + alpha=-1.1, + ) + + np.testing.assert_allclose(result, expected) + + +def test_two_component_lf_accepts_scalar_magnitude_input() -> None: + """Tests that two_component_lf accepts scalar absolute magnitude input.""" + result = two_component_lf( + -21.0, + lognormal_mean_absolute_mag=-21.5, + lognormal_sigma_log_luminosity=0.25, + modified_phi_star=0.004, + modified_alpha=-1.1, + ) + + assert np.shape(result) == () + assert np.isfinite(result) + assert result >= 0.0 + + +def test_two_component_lf_preserves_array_shape() -> None: + """Tests that two_component_lf preserves the absolute magnitude shape.""" + absolute_mag = np.array([[-23.0, -22.0], [-21.0, -20.0]]) + + result = two_component_lf( + absolute_mag, + lognormal_mean_absolute_mag=-21.5, + lognormal_sigma_log_luminosity=0.25, + modified_phi_star=0.004, + modified_alpha=-1.1, + ) + + assert result.shape == absolute_mag.shape + + +def test_two_component_lf_rejects_nonfinite_absolute_magnitude() -> None: + """Tests that two_component_lf rejects non-finite absolute magnitudes.""" + with pytest.raises(ValueError, match="absolute_mag contains NaN or infinite values"): + two_component_lf( + np.array([-22.0, np.nan]), + lognormal_mean_absolute_mag=-21.5, + lognormal_sigma_log_luminosity=0.25, + modified_phi_star=0.004, + modified_alpha=-1.1, + ) + + +def test_two_component_lf_rejects_nonfinite_lognormal_mean() -> None: + """Tests that two_component_lf rejects non-finite lognormal mean magnitudes.""" + with pytest.raises( + ValueError, + match="lognormal_mean_absolute_mag contains NaN or infinite values", + ): + two_component_lf( + np.array([-22.0, -21.0]), + lognormal_mean_absolute_mag=np.nan, + lognormal_sigma_log_luminosity=0.25, + modified_phi_star=0.004, + modified_alpha=-1.1, + ) + + +def test_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_lf( + np.array([-22.0, -21.0]), + lognormal_mean_absolute_mag=-21.5, + lognormal_sigma_log_luminosity=0.25, + modified_phi_star=0.004, + modified_alpha=-1.1, + modified_luminosity_fraction=0.0, + ) + + +def test_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_lf( + np.array([-22.0, -21.0]), + lognormal_mean_absolute_mag=-21.5, + lognormal_sigma_log_luminosity=0.25, + modified_phi_star=0.004, + modified_alpha=-1.1, + modified_luminosity_fraction=-0.5, + ) + + +def test_two_component_lf_rejects_nonfinite_luminosity_fraction() -> None: + """Tests that non-finite modified_luminosity_fraction is rejected.""" + with pytest.raises( + ValueError, + match="modified_luminosity_fraction contains NaN or infinite values", + ): + two_component_lf( + np.array([-22.0, -21.0]), + lognormal_mean_absolute_mag=-21.5, + lognormal_sigma_log_luminosity=0.25, + modified_phi_star=0.004, + modified_alpha=-1.1, + modified_luminosity_fraction=np.nan, + ) + + +def test_two_component_lf_rejects_nonfinite_explicit_m_star() -> None: + """Tests that non-finite explicit modified_m_star values are rejected.""" + with pytest.raises( + ValueError, + match="modified_m_star contains NaN or infinite values", + ): + two_component_lf( + np.array([-22.0, -21.0]), + lognormal_mean_absolute_mag=-21.5, + lognormal_sigma_log_luminosity=0.25, + modified_phi_star=0.004, + modified_m_star=np.inf, + modified_alpha=-1.1, + ) + + +def test_two_component_lf_explicit_m_star_overrides_bad_luminosity_fraction() -> None: + """Tests that modified_luminosity_fraction is ignored when modified_m_star is explicit.""" + result = two_component_lf( + np.array([-22.0, -21.0]), + lognormal_mean_absolute_mag=-21.5, + lognormal_sigma_log_luminosity=0.25, + modified_phi_star=0.004, + modified_m_star=-20.8, + modified_alpha=-1.1, + modified_luminosity_fraction=np.nan, + ) + + assert np.all(np.isfinite(result)) + + +def test_two_component_lf_allows_array_lognormal_mean() -> None: + """Tests that array-valued lognormal means broadcast through the model.""" + absolute_mag = np.array([-23.0, -22.0, -21.0]) + mean_mag = np.array([-21.5, -21.0, -20.5]) + + result = two_component_lf( + absolute_mag, + lognormal_mean_absolute_mag=mean_mag, + lognormal_sigma_log_luminosity=0.25, + modified_phi_star=0.004, + modified_alpha=-1.1, + ) + + assert result.shape == absolute_mag.shape + assert np.all(np.isfinite(result)) + + +def test_two_component_lf_allows_array_luminosity_fraction() -> None: + """Tests that array-valued luminosity fractions broadcast through the model.""" + absolute_mag = np.array([-23.0, -22.0, -21.0]) + luminosity_fraction = np.array([0.5, 0.6, 0.7]) + + result = two_component_lf( + absolute_mag, + lognormal_mean_absolute_mag=-21.5, + lognormal_sigma_log_luminosity=0.25, + modified_phi_star=0.004, + modified_alpha=-1.1, + modified_luminosity_fraction=luminosity_fraction, + ) + + assert result.shape == absolute_mag.shape + assert np.all(np.isfinite(result)) + + +def test_two_component_lf_zero_lognormal_amplitude_matches_modified_component() -> None: + """Tests that zero lognormal amplitude leaves only the modified Schechter component.""" + absolute_mag = np.array([-23.0, -22.0, -21.0]) + modified_m_star = -20.8 + + result = two_component_lf( + absolute_mag, + lognormal_mean_absolute_mag=-21.5, + lognormal_sigma_log_luminosity=0.25, + lognormal_amplitude=0.0, + modified_phi_star=0.004, + modified_m_star=modified_m_star, + modified_alpha=-1.1, + ) + + expected = apply_luminosity_cutoff( + absolute_mag, + base_lf=schechter, + phi_star=0.004, + m_star=modified_m_star, + alpha=-1.1, + ) + + np.testing.assert_allclose(result, expected) diff --git a/tests/test_lumfuncs_models_gaussian.py b/tests/test_lumfuncs_models_gaussian.py new file mode 100644 index 00000000..3fa86cba --- /dev/null +++ b/tests/test_lumfuncs_models_gaussian.py @@ -0,0 +1,420 @@ +"""Unit tests for ``lfkit.luminosity_functions.models.gaussian``.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from lfkit.luminosity_functions.models.gaussian import gaussian_lf, lognormal_lf + + +def test_gaussian_lf_matches_manual_formula() -> None: + """Tests that gaussian_lf matches the analytic Gaussian formula.""" + absolute_mag = np.array([-22.0, -21.0, -20.0]) + mean = -21.0 + sigma = 0.5 + amplitude = 2.0 + + result = gaussian_lf( + absolute_mag, + mean_absolute_mag=mean, + sigma_absolute_mag=sigma, + amplitude=amplitude, + ) + + expected = ( + amplitude + / (np.sqrt(2.0 * np.pi) * sigma) + * np.exp(-0.5 * ((absolute_mag - mean) / sigma) ** 2.0) + ) + np.testing.assert_allclose(result, expected) + + +def test_gaussian_lf_peak_value_matches_normalization() -> None: + """Tests that gaussian_lf has the expected value at the mean.""" + result = gaussian_lf( + -21.0, + mean_absolute_mag=-21.0, + sigma_absolute_mag=0.5, + amplitude=2.0, + ) + + expected = 2.0 / (np.sqrt(2.0 * np.pi) * 0.5) + np.testing.assert_allclose(result, expected) + + +def test_gaussian_lf_is_symmetric_about_mean() -> None: + """Tests that gaussian_lf is symmetric in magnitude around the mean.""" + result = gaussian_lf( + np.array([-22.0, -20.0]), + mean_absolute_mag=-21.0, + sigma_absolute_mag=0.5, + amplitude=1.0, + ) + + np.testing.assert_allclose(result[0], result[1]) + + +def test_gaussian_lf_accepts_scalar_input() -> None: + """Tests that gaussian_lf accepts scalar magnitude input.""" + result = gaussian_lf( + -21.0, + mean_absolute_mag=-21.0, + sigma_absolute_mag=1.0, + ) + + assert np.shape(result) == () + assert np.isfinite(result) + + +def test_gaussian_lf_preserves_array_shape() -> None: + """Tests that gaussian_lf preserves the input magnitude shape.""" + absolute_mag = np.array([[-22.0, -21.0], [-20.0, -19.0]]) + + result = gaussian_lf( + absolute_mag, + mean_absolute_mag=-21.0, + sigma_absolute_mag=1.0, + ) + + assert result.shape == absolute_mag.shape + + +def test_gaussian_lf_returns_float_array() -> None: + """Tests that gaussian_lf returns floating-point values.""" + result = gaussian_lf( + np.array([-22, -21, -20]), + mean_absolute_mag=-21, + sigma_absolute_mag=1, + ) + + assert result.dtype.kind == "f" + + +def test_gaussian_lf_zero_amplitude_returns_zero() -> None: + """Tests that zero amplitude returns zero luminosity function values.""" + result = gaussian_lf( + np.array([-22.0, -21.0, -20.0]), + mean_absolute_mag=-21.0, + sigma_absolute_mag=1.0, + amplitude=0.0, + ) + + np.testing.assert_allclose(result, np.zeros(3)) + + +def test_gaussian_lf_scales_linearly_with_amplitude() -> None: + """Tests that gaussian_lf scales linearly with amplitude.""" + absolute_mag = np.array([-22.0, -21.0, -20.0]) + + result_1 = gaussian_lf( + absolute_mag, + mean_absolute_mag=-21.0, + sigma_absolute_mag=1.0, + amplitude=1.0, + ) + result_2 = gaussian_lf( + absolute_mag, + mean_absolute_mag=-21.0, + sigma_absolute_mag=1.0, + amplitude=2.0, + ) + + np.testing.assert_allclose(result_2, 2.0 * result_1) + + +def test_gaussian_lf_allows_array_parameters() -> None: + """Tests that gaussian_lf supports array-valued broadcasted parameters.""" + absolute_mag = np.array([-22.0, -21.0, -20.0]) + mean = np.array([-21.5, -21.0, -20.5]) + + result = gaussian_lf( + absolute_mag, + mean_absolute_mag=mean, + sigma_absolute_mag=0.5, + amplitude=np.array([1.0, 2.0, 3.0]), + ) + + assert result.shape == absolute_mag.shape + assert np.all(np.isfinite(result)) + + +def test_gaussian_lf_rejects_zero_sigma() -> None: + """Tests that zero sigma_absolute_mag is rejected.""" + with pytest.raises(ValueError, match="sigma_absolute_mag must be positive"): + gaussian_lf( + np.array([-22.0, -21.0]), + mean_absolute_mag=-21.0, + sigma_absolute_mag=0.0, + ) + + +def test_gaussian_lf_rejects_negative_sigma() -> None: + """Tests that negative sigma_absolute_mag is rejected.""" + with pytest.raises(ValueError, match="sigma_absolute_mag must be positive"): + gaussian_lf( + np.array([-22.0, -21.0]), + mean_absolute_mag=-21.0, + sigma_absolute_mag=-1.0, + ) + + +def test_gaussian_lf_rejects_negative_amplitude() -> None: + """Tests that negative amplitude is rejected.""" + with pytest.raises(ValueError, match="amplitude must be non-negative"): + gaussian_lf( + np.array([-22.0, -21.0]), + mean_absolute_mag=-21.0, + sigma_absolute_mag=1.0, + amplitude=-1.0, + ) + + +@pytest.mark.parametrize( + ("parameter_name", "kwargs", "match"), + [ + ( + "absolute_mag", + {"absolute_mag": np.array([-22.0, np.nan])}, + "absolute_mag contains NaN or infinite values", + ), + ( + "mean_absolute_mag", + {"mean_absolute_mag": np.nan}, + "mean_absolute_mag contains NaN or infinite values", + ), + ( + "sigma_absolute_mag", + {"sigma_absolute_mag": np.inf}, + "sigma_absolute_mag contains NaN or infinite values", + ), + ( + "amplitude", + {"amplitude": np.nan}, + "amplitude contains NaN or infinite values", + ), + ], +) +def test_gaussian_lf_rejects_nonfinite_inputs( + parameter_name: str, + kwargs: dict[str, object], + match: str, +) -> None: + """Tests that gaussian_lf rejects non-finite inputs.""" + params = { + "absolute_mag": np.array([-22.0, -21.0]), + "mean_absolute_mag": -21.0, + "sigma_absolute_mag": 1.0, + "amplitude": 1.0, + } + params.update(kwargs) + + with pytest.raises(ValueError, match=match): + gaussian_lf(**params) + + +def test_lognormal_lf_matches_manual_formula() -> None: + """Tests that lognormal_lf matches the analytic lognormal-in-luminosity formula.""" + absolute_mag = np.array([-22.0, -21.0, -20.0]) + mean = -21.0 + sigma_log_luminosity = 0.25 + amplitude = 2.0 + + result = lognormal_lf( + absolute_mag, + mean_absolute_mag=mean, + sigma_log_luminosity=sigma_log_luminosity, + amplitude=amplitude, + ) + + delta_log_luminosity = -0.4 * (absolute_mag - mean) + expected = ( + amplitude + * 0.4 + / (np.sqrt(2.0 * np.pi) * sigma_log_luminosity) + * np.exp(-0.5 * (delta_log_luminosity / sigma_log_luminosity) ** 2.0) + ) + np.testing.assert_allclose(result, expected) + + +def test_lognormal_lf_peak_value_matches_normalization() -> None: + """Tests that lognormal_lf has the expected value at the mean.""" + result = lognormal_lf( + -21.0, + mean_absolute_mag=-21.0, + sigma_log_luminosity=0.25, + amplitude=2.0, + ) + + expected = 2.0 * 0.4 / (np.sqrt(2.0 * np.pi) * 0.25) + np.testing.assert_allclose(result, expected) + + +def test_lognormal_lf_is_symmetric_in_log_luminosity_about_mean() -> None: + """Tests that lognormal_lf is symmetric around the mean magnitude.""" + result = lognormal_lf( + np.array([-22.0, -20.0]), + mean_absolute_mag=-21.0, + sigma_log_luminosity=0.25, + amplitude=1.0, + ) + + np.testing.assert_allclose(result[0], result[1]) + + +def test_lognormal_lf_accepts_scalar_input() -> None: + """Tests that lognormal_lf accepts scalar magnitude input.""" + result = lognormal_lf( + -21.0, + mean_absolute_mag=-21.0, + sigma_log_luminosity=0.25, + ) + + assert np.shape(result) == () + assert np.isfinite(result) + + +def test_lognormal_lf_preserves_array_shape() -> None: + """Tests that lognormal_lf preserves the input magnitude shape.""" + absolute_mag = np.array([[-22.0, -21.0], [-20.0, -19.0]]) + + result = lognormal_lf( + absolute_mag, + mean_absolute_mag=-21.0, + sigma_log_luminosity=0.25, + ) + + assert result.shape == absolute_mag.shape + + +def test_lognormal_lf_returns_float_array() -> None: + """Tests that lognormal_lf returns floating-point values.""" + result = lognormal_lf( + np.array([-22, -21, -20]), + mean_absolute_mag=-21, + sigma_log_luminosity=1, + ) + + assert result.dtype.kind == "f" + + +def test_lognormal_lf_zero_amplitude_returns_zero() -> None: + """Tests that zero amplitude returns zero lognormal luminosity function values.""" + result = lognormal_lf( + np.array([-22.0, -21.0, -20.0]), + mean_absolute_mag=-21.0, + sigma_log_luminosity=0.25, + amplitude=0.0, + ) + + np.testing.assert_allclose(result, np.zeros(3)) + + +def test_lognormal_lf_scales_linearly_with_amplitude() -> None: + """Tests that lognormal_lf scales linearly with amplitude.""" + absolute_mag = np.array([-22.0, -21.0, -20.0]) + + result_1 = lognormal_lf( + absolute_mag, + mean_absolute_mag=-21.0, + sigma_log_luminosity=0.25, + amplitude=1.0, + ) + result_2 = lognormal_lf( + absolute_mag, + mean_absolute_mag=-21.0, + sigma_log_luminosity=0.25, + amplitude=2.0, + ) + + np.testing.assert_allclose(result_2, 2.0 * result_1) + + +def test_lognormal_lf_allows_array_parameters() -> None: + """Tests that lognormal_lf supports array-valued broadcasted parameters.""" + absolute_mag = np.array([-22.0, -21.0, -20.0]) + mean = np.array([-21.5, -21.0, -20.5]) + + result = lognormal_lf( + absolute_mag, + mean_absolute_mag=mean, + sigma_log_luminosity=0.25, + amplitude=np.array([1.0, 2.0, 3.0]), + ) + + assert result.shape == absolute_mag.shape + assert np.all(np.isfinite(result)) + + +def test_lognormal_lf_rejects_zero_sigma() -> None: + """Tests that zero sigma_log_luminosity is rejected.""" + with pytest.raises(ValueError, match="sigma_log_luminosity must be positive"): + lognormal_lf( + np.array([-22.0, -21.0]), + mean_absolute_mag=-21.0, + sigma_log_luminosity=0.0, + ) + + +def test_lognormal_lf_rejects_negative_sigma() -> None: + """Tests that negative sigma_log_luminosity is rejected.""" + with pytest.raises(ValueError, match="sigma_log_luminosity must be positive"): + lognormal_lf( + np.array([-22.0, -21.0]), + mean_absolute_mag=-21.0, + sigma_log_luminosity=-0.25, + ) + + +def test_lognormal_lf_rejects_negative_amplitude() -> None: + """Tests that negative amplitude is rejected.""" + with pytest.raises(ValueError, match="amplitude must be non-negative"): + lognormal_lf( + np.array([-22.0, -21.0]), + mean_absolute_mag=-21.0, + sigma_log_luminosity=0.25, + amplitude=-1.0, + ) + + +@pytest.mark.parametrize( + ("parameter_name", "kwargs", "match"), + [ + ( + "absolute_mag", + {"absolute_mag": np.array([-22.0, np.nan])}, + "absolute_mag contains NaN or infinite values", + ), + ( + "mean_absolute_mag", + {"mean_absolute_mag": np.nan}, + "mean_absolute_mag contains NaN or infinite values", + ), + ( + "sigma_log_luminosity", + {"sigma_log_luminosity": np.inf}, + "sigma_log_luminosity contains NaN or infinite values", + ), + ( + "amplitude", + {"amplitude": np.nan}, + "amplitude contains NaN or infinite values", + ), + ], +) +def test_lognormal_lf_rejects_nonfinite_inputs( + parameter_name: str, + kwargs: dict[str, object], + match: str, +) -> None: + """Tests that lognormal_lf rejects non-finite inputs.""" + params = { + "absolute_mag": np.array([-22.0, -21.0]), + "mean_absolute_mag": -21.0, + "sigma_log_luminosity": 0.25, + "amplitude": 1.0, + } + params.update(kwargs) + + with pytest.raises(ValueError, match=match): + lognormal_lf(**params) diff --git a/tests/test_lumfuncs_models_modifiers.py b/tests/test_lumfuncs_models_modifiers.py new file mode 100644 index 00000000..26649e2b --- /dev/null +++ b/tests/test_lumfuncs_models_modifiers.py @@ -0,0 +1,193 @@ +"""Unit tests for ``lfkit.luminosity_functions.models.modifiers``.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from lfkit.luminosity_functions.models.modifiers import apply_luminosity_cutoff +from lfkit.photometry.luminosities import luminosity_ratio + + +def constant_lf(absolute_mag, *, m_star, amplitude=1.0): + """Return a constant base luminosity function.""" + return amplitude * np.ones_like(absolute_mag, dtype=float) + + +def linear_lf(absolute_mag, *, m_star, slope=1.0): + """Return a simple magnitude-dependent base luminosity function.""" + return slope * (np.asarray(absolute_mag, dtype=float) - m_star) + + +def test_apply_luminosity_cutoff_matches_manual_formula() -> None: + """Tests that apply_luminosity_cutoff matches exp(-A x**p).""" + absolute_mag = np.array([-23.0, -22.0, -21.0]) + m_star = -21.0 + cutoff_power = 2.0 + cutoff_amplitude = 0.5 + + result = apply_luminosity_cutoff( + absolute_mag, + base_lf=constant_lf, + m_star=m_star, + cutoff_power=cutoff_power, + cutoff_amplitude=cutoff_amplitude, + amplitude=3.0, + ) + + x = luminosity_ratio(absolute_mag, m_star) + expected = 3.0 * np.exp(-cutoff_amplitude * x**cutoff_power) + + np.testing.assert_allclose(result, expected) + + +def test_apply_luminosity_cutoff_passes_parameters_to_base_lf() -> None: + """Tests that extra keyword parameters are forwarded to the base LF.""" + absolute_mag = np.array([-23.0, -22.0, -21.0]) + m_star = -22.0 + + result = apply_luminosity_cutoff( + absolute_mag, + base_lf=linear_lf, + m_star=m_star, + cutoff_amplitude=0.0, + slope=2.0, + ) + + expected = linear_lf(absolute_mag, m_star=m_star, slope=2.0) + np.testing.assert_allclose(result, expected) + + +def test_apply_luminosity_cutoff_zero_amplitude_leaves_base_lf_unchanged() -> None: + """Tests that zero cutoff amplitude gives the unmodified base LF.""" + absolute_mag = np.array([-23.0, -22.0, -21.0]) + + result = apply_luminosity_cutoff( + absolute_mag, + base_lf=constant_lf, + m_star=-22.0, + cutoff_amplitude=0.0, + amplitude=4.0, + ) + + np.testing.assert_allclose(result, np.full_like(absolute_mag, 4.0)) + + +def test_apply_luminosity_cutoff_accepts_scalar_input() -> None: + """Tests that scalar magnitude input is accepted.""" + result = apply_luminosity_cutoff( + -21.0, + base_lf=constant_lf, + m_star=-21.0, + ) + + assert np.shape(result) == () + assert np.isfinite(result) + + +def test_apply_luminosity_cutoff_preserves_array_shape() -> None: + """Tests that the modified luminosity function preserves input shape.""" + absolute_mag = np.array([[-23.0, -22.0], [-21.0, -20.0]]) + + result = apply_luminosity_cutoff( + absolute_mag, + base_lf=constant_lf, + m_star=-21.0, + ) + + assert result.shape == absolute_mag.shape + + +def test_apply_luminosity_cutoff_returns_float_array() -> None: + """Tests that the returned values are floating point.""" + result = apply_luminosity_cutoff( + np.array([-23, -22, -21]), + base_lf=constant_lf, + m_star=-21, + ) + + assert result.dtype.kind == "f" + + +def test_apply_luminosity_cutoff_rejects_zero_cutoff_power() -> None: + """Tests that zero cutoff power is rejected.""" + with pytest.raises(ValueError, match="cutoff_power must be positive"): + apply_luminosity_cutoff( + np.array([-22.0, -21.0]), + base_lf=constant_lf, + m_star=-21.0, + cutoff_power=0.0, + ) + + +def test_apply_luminosity_cutoff_rejects_negative_cutoff_power() -> None: + """Tests that negative cutoff power is rejected.""" + with pytest.raises(ValueError, match="cutoff_power must be positive"): + apply_luminosity_cutoff( + np.array([-22.0, -21.0]), + base_lf=constant_lf, + m_star=-21.0, + cutoff_power=-1.0, + ) + + +def test_apply_luminosity_cutoff_rejects_negative_cutoff_amplitude() -> None: + """Tests that negative cutoff amplitude is rejected.""" + with pytest.raises(ValueError, match="cutoff_amplitude must be non-negative"): + apply_luminosity_cutoff( + np.array([-22.0, -21.0]), + base_lf=constant_lf, + m_star=-21.0, + cutoff_amplitude=-1.0, + ) + + +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ( + {"absolute_mag": np.array([-22.0, np.nan])}, + "absolute_mag contains NaN or infinite values", + ), + ( + {"cutoff_power": np.nan}, + "cutoff_power contains NaN or infinite values", + ), + ( + {"cutoff_amplitude": np.inf}, + "cutoff_amplitude contains NaN or infinite values", + ), + ], +) +def test_apply_luminosity_cutoff_rejects_nonfinite_inputs( + kwargs: dict[str, object], + match: str, +) -> None: + """Tests that non-finite modifier inputs are rejected.""" + params = { + "absolute_mag": np.array([-22.0, -21.0]), + "base_lf": constant_lf, + "m_star": -21.0, + "cutoff_power": 2.0, + "cutoff_amplitude": 1.0, + } + params.update(kwargs) + + with pytest.raises(ValueError, match=match): + apply_luminosity_cutoff(**params) + + +def test_apply_luminosity_cutoff_allows_array_cutoff_parameters() -> None: + """Tests that array-valued cutoff parameters broadcast through the modifier.""" + absolute_mag = np.array([-23.0, -22.0, -21.0]) + + result = apply_luminosity_cutoff( + absolute_mag, + base_lf=constant_lf, + m_star=-21.0, + cutoff_power=np.array([1.0, 2.0, 3.0]), + cutoff_amplitude=np.array([0.1, 0.2, 0.3]), + ) + + assert result.shape == absolute_mag.shape + assert np.all(np.isfinite(result)) diff --git a/tests/test_lumfuncs_models_power_law.py b/tests/test_lumfuncs_models_power_law.py new file mode 100644 index 00000000..249740be --- /dev/null +++ b/tests/test_lumfuncs_models_power_law.py @@ -0,0 +1,417 @@ +"""Unit tests for ``lfkit.luminosity_functions.models.power_law``.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from lfkit.luminosity_functions.models.power_law import ( + broken_power_law_lf, + double_power_law_lf, + log_power_law_lf, + power_law_lf, +) +from lfkit.photometry.luminosities import luminosity_ratio + + +def test_power_law_lf_matches_manual_formula() -> None: + """Tests that power_law_lf matches the analytic formula.""" + absolute_mag = np.array([-23.0, -22.0, -21.0]) + phi_star = 0.01 + m_star = -21.0 + alpha = -1.2 + + result = power_law_lf( + absolute_mag, + 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.testing.assert_allclose(result, expected) + + +def test_power_law_lf_accepts_scalar_input() -> None: + """Tests that power_law_lf accepts scalar magnitude input.""" + result = power_law_lf(-21.0, phi_star=0.01, m_star=-21.0, alpha=-1.0) + + assert np.shape(result) == () + assert np.isfinite(result) + + +def test_power_law_lf_preserves_array_shape() -> None: + """Tests that power_law_lf preserves input shape.""" + absolute_mag = np.array([[-23.0, -22.0], [-21.0, -20.0]]) + + result = power_law_lf( + absolute_mag, + phi_star=0.01, + m_star=-21.0, + alpha=-1.0, + ) + + assert result.shape == absolute_mag.shape + + +def test_power_law_lf_zero_phi_star_returns_zero() -> None: + """Tests that zero phi_star returns zero values.""" + result = power_law_lf( + np.array([-23.0, -22.0, -21.0]), + phi_star=0.0, + m_star=-21.0, + alpha=-1.0, + ) + + np.testing.assert_allclose(result, np.zeros(3)) + + +def test_power_law_lf_rejects_negative_phi_star() -> None: + """Tests that negative phi_star is rejected.""" + with pytest.raises(ValueError, match="phi_star must be non-negative"): + power_law_lf( + np.array([-23.0, -22.0]), + phi_star=-0.01, + m_star=-21.0, + alpha=-1.0, + ) + + +def test_double_power_law_lf_matches_manual_formula() -> None: + """Tests that double_power_law_lf matches the analytic formula.""" + absolute_mag = np.array([-23.0, -22.0, -21.0]) + phi_star = 0.01 + m_star = -21.0 + alpha = -1.2 + beta = -3.0 + + result = double_power_law_lf( + absolute_mag, + phi_star=phi_star, + m_star=m_star, + alpha=alpha, + beta=beta, + ) + + x = luminosity_ratio(absolute_mag, m_star) + expected = ( + 0.4 + * np.log(10.0) + * phi_star + / (x ** (-(alpha + 1.0)) + x ** (-(beta + 1.0))) + ) + + np.testing.assert_allclose(result, expected) + + +def test_double_power_law_lf_value_at_m_star() -> None: + """Tests that double_power_law_lf has the expected value at x=1.""" + phi_star = 0.01 + + result = double_power_law_lf( + -21.0, + phi_star=phi_star, + m_star=-21.0, + alpha=-1.2, + beta=-3.0, + ) + + expected = 0.5 * 0.4 * np.log(10.0) * phi_star + np.testing.assert_allclose(result, expected) + + +def test_double_power_law_lf_preserves_array_shape() -> None: + """Tests that double_power_law_lf preserves input shape.""" + absolute_mag = np.array([[-23.0, -22.0], [-21.0, -20.0]]) + + result = double_power_law_lf( + absolute_mag, + phi_star=0.01, + m_star=-21.0, + alpha=-1.2, + beta=-3.0, + ) + + assert result.shape == absolute_mag.shape + + +def test_double_power_law_lf_rejects_negative_phi_star() -> None: + """Tests that negative phi_star is rejected.""" + with pytest.raises(ValueError, match="phi_star must be non-negative"): + double_power_law_lf( + np.array([-23.0, -22.0]), + phi_star=-0.01, + m_star=-21.0, + alpha=-1.2, + beta=-3.0, + ) + + +def test_broken_power_law_lf_matches_manual_formula() -> None: + """Tests that broken_power_law_lf matches the piecewise analytic formula.""" + absolute_mag = np.array([-22.0, -21.0, -20.0]) + phi_star = 0.01 + m_star = -21.0 + alpha_faint = -1.1 + alpha_bright = -3.0 + + result = broken_power_law_lf( + absolute_mag, + phi_star=phi_star, + m_star=m_star, + alpha_faint=alpha_faint, + alpha_bright=alpha_bright, + ) + + x = luminosity_ratio(absolute_mag, m_star) + expected = np.where( + x < 1.0, + phi_star * x ** (alpha_faint + 1.0), + phi_star * x ** (alpha_bright + 1.0), + ) + expected = 0.4 * np.log(10.0) * expected + + np.testing.assert_allclose(result, expected) + + +def test_broken_power_law_lf_uses_bright_branch_at_break() -> None: + """Tests that x=1 uses the bright-side branch.""" + phi_star = 0.01 + + result = broken_power_law_lf( + -21.0, + phi_star=phi_star, + m_star=-21.0, + alpha_faint=-1.1, + alpha_bright=-3.0, + ) + + expected = 0.4 * np.log(10.0) * phi_star + np.testing.assert_allclose(result, expected) + + +def test_broken_power_law_lf_preserves_array_shape() -> None: + """Tests that broken_power_law_lf preserves input shape.""" + absolute_mag = np.array([[-23.0, -22.0], [-21.0, -20.0]]) + + result = broken_power_law_lf( + absolute_mag, + phi_star=0.01, + m_star=-21.0, + alpha_faint=-1.1, + alpha_bright=-3.0, + ) + + assert result.shape == absolute_mag.shape + + +def test_broken_power_law_lf_rejects_negative_phi_star() -> None: + """Tests that negative phi_star is rejected.""" + with pytest.raises(ValueError, match="phi_star must be non-negative"): + broken_power_law_lf( + np.array([-23.0, -22.0]), + phi_star=-0.01, + m_star=-21.0, + alpha_faint=-1.1, + alpha_bright=-3.0, + ) + + +def test_log_power_law_lf_matches_power_law_lf() -> None: + """Tests that log_power_law_lf matches power_law_lf with phi_star=10**log_phi_star.""" + absolute_mag = np.array([-23.0, -22.0, -21.0]) + log_phi_star = -2.0 + + result = log_power_law_lf( + absolute_mag, + log_phi_star=log_phi_star, + m_star=-21.0, + alpha=-1.2, + ) + + expected = power_law_lf( + absolute_mag, + phi_star=10.0**log_phi_star, + m_star=-21.0, + alpha=-1.2, + ) + + np.testing.assert_allclose(result, expected) + + +def test_log_power_law_lf_accepts_array_log_phi_star() -> None: + """Tests that log_power_law_lf supports array-valued log_phi_star.""" + absolute_mag = np.array([-23.0, -22.0, -21.0]) + + result = log_power_law_lf( + absolute_mag, + log_phi_star=np.array([-3.0, -2.0, -1.0]), + m_star=-21.0, + alpha=-1.2, + ) + + assert result.shape == absolute_mag.shape + assert np.all(np.isfinite(result)) + + +@pytest.mark.parametrize( + ("function", "kwargs"), + [ + ( + power_law_lf, + {"phi_star": 0.01, "m_star": -21.0, "alpha": -1.2}, + ), + ( + double_power_law_lf, + {"phi_star": 0.01, "m_star": -21.0, "alpha": -1.2, "beta": -3.0}, + ), + ( + broken_power_law_lf, + { + "phi_star": 0.01, + "m_star": -21.0, + "alpha_faint": -1.1, + "alpha_bright": -3.0, + }, + ), + ( + log_power_law_lf, + {"log_phi_star": -2.0, "m_star": -21.0, "alpha": -1.2}, + ), + ], +) +def test_power_law_models_accept_scalar_inputs(function, kwargs: dict[str, object]) -> None: + """Tests that all power-law models accept scalar magnitude input.""" + result = function(-21.0, **kwargs) + + assert np.shape(result) == () + assert np.isfinite(result) + + +@pytest.mark.parametrize( + ("function", "kwargs"), + [ + ( + power_law_lf, + {"phi_star": 0.01, "m_star": -21.0, "alpha": -1.2}, + ), + ( + double_power_law_lf, + {"phi_star": 0.01, "m_star": -21.0, "alpha": -1.2, "beta": -3.0}, + ), + ( + broken_power_law_lf, + { + "phi_star": 0.01, + "m_star": -21.0, + "alpha_faint": -1.1, + "alpha_bright": -3.0, + }, + ), + ( + log_power_law_lf, + {"log_phi_star": -2.0, "m_star": -21.0, "alpha": -1.2}, + ), + ], +) +def test_power_law_models_reject_nonfinite_absolute_mag( + function, + kwargs: dict[str, object], +) -> None: + """Tests that all power-law models reject non-finite magnitudes.""" + with pytest.raises(ValueError, match="absolute_mag contains NaN or infinite values"): + function(np.array([-22.0, np.nan]), **kwargs) + + +@pytest.mark.parametrize( + ("function", "kwargs", "bad_key", "match"), + [ + ( + power_law_lf, + {"phi_star": 0.01, "m_star": -21.0, "alpha": -1.2}, + "phi_star", + "phi_star contains NaN or infinite values", + ), + ( + power_law_lf, + {"phi_star": 0.01, "m_star": -21.0, "alpha": -1.2}, + "alpha", + "alpha contains NaN or infinite values", + ), + ( + double_power_law_lf, + {"phi_star": 0.01, "m_star": -21.0, "alpha": -1.2, "beta": -3.0}, + "beta", + "beta contains NaN or infinite values", + ), + ( + broken_power_law_lf, + { + "phi_star": 0.01, + "m_star": -21.0, + "alpha_faint": -1.1, + "alpha_bright": -3.0, + }, + "alpha_faint", + "alpha_faint contains NaN or infinite values", + ), + ( + broken_power_law_lf, + { + "phi_star": 0.01, + "m_star": -21.0, + "alpha_faint": -1.1, + "alpha_bright": -3.0, + }, + "alpha_bright", + "alpha_bright contains NaN or infinite values", + ), + ( + log_power_law_lf, + {"log_phi_star": -2.0, "m_star": -21.0, "alpha": -1.2}, + "log_phi_star", + "log_phi_star contains NaN or infinite values", + ), + ], +) +def test_power_law_models_reject_nonfinite_validated_parameters( + function, + kwargs: dict[str, object], + bad_key: str, + match: str, +) -> None: + """Tests that validated power-law parameters reject non-finite values.""" + kwargs = dict(kwargs) + kwargs[bad_key] = np.nan + + with pytest.raises(ValueError, match=match): + function(np.array([-22.0, -21.0]), **kwargs) + + +def test_power_law_models_return_float_arrays() -> None: + """Tests that all power-law model outputs are floating point.""" + absolute_mag = np.array([-23, -22, -21]) + + results = [ + power_law_lf(absolute_mag, phi_star=1, m_star=-21, alpha=-1), + double_power_law_lf( + absolute_mag, + phi_star=1, + m_star=-21, + alpha=-1, + beta=-3, + ), + broken_power_law_lf( + absolute_mag, + phi_star=1, + m_star=-21, + alpha_faint=-1, + alpha_bright=-3, + ), + log_power_law_lf(absolute_mag, log_phi_star=-2, m_star=-21, alpha=-1), + ] + + for result in results: + assert result.dtype.kind == "f" diff --git a/tests/test_lumfuncs_models_schechter.py b/tests/test_lumfuncs_models_schechter.py index 3c6c955c..d133b1d3 100644 --- a/tests/test_lumfuncs_models_schechter.py +++ b/tests/test_lumfuncs_models_schechter.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.photometry.luminosity_function.py``.""" +"""Unit tests for ``lfkit.luminosity_functions.models.schechter``.""" import numpy as np import pytest @@ -252,3 +252,224 @@ def test_evolving_schechter_missing_kwargs(): [0.5], phi_model="linear_p", # requires phi_0_star and p ) + + +def test_schechter_matches_manual_formula() -> None: + """Tests that schechter matches the analytic magnitude-space formula.""" + m = np.array([-22.0, -21.0, -20.0]) + phi_star = 1e-3 + m_star = -20.0 + alpha = -1.2 + + result = schechter(m, phi_star=phi_star, m_star=m_star, alpha=alpha) + + x = luminosity_ratio(m, m_star) + expected = 0.4 * np.log(10.0) * phi_star * x ** (alpha + 1.0) * np.exp(-x) + + np.testing.assert_allclose(result, expected) + + +def test_schechter_rejects_nonfinite_phi_star() -> None: + """Tests that schechter rejects non-finite phi_star.""" + with pytest.raises(ValueError, match="phi_star contains NaN or infinite values"): + schechter([-20.0], phi_star=np.nan, m_star=-20.0, alpha=-1.0) + + +def test_schechter_rejects_nonfinite_alpha() -> None: + """Tests that schechter rejects non-finite alpha.""" + with pytest.raises(ValueError, match="alpha contains NaN or infinite values"): + schechter([-20.0], phi_star=1e-3, m_star=-20.0, alpha=np.inf) + + +def test_schechter_allows_array_parameters() -> None: + """Tests that schechter supports array-valued parameters.""" + m = np.array([-22.0, -21.0, -20.0]) + + result = schechter( + m, + phi_star=np.array([1e-3, 2e-3, 3e-3]), + m_star=-20.0, + alpha=np.array([-1.2, -1.0, -0.8]), + ) + + assert result.shape == m.shape + assert np.all(np.isfinite(result)) + assert np.all(result >= 0.0) + + +def test_double_schechter_matches_manual_formula() -> None: + """Tests that double_schechter matches the analytic formula.""" + m = np.array([-22.0, -21.0, -20.0]) + phi_star = 1e-3 + m_star = -20.0 + alpha = -1.2 + beta = 1.5 + m_transition = -18.0 + + result = double_schechter( + m, + phi_star=phi_star, + m_star=m_star, + alpha=alpha, + beta=beta, + m_transition=m_transition, + ) + + x = luminosity_ratio(m, m_star) + x_t = luminosity_ratio(m_transition, m_star) + expected = ( + 0.4 + * np.log(10.0) + * phi_star + * x ** (alpha + 1.0) + * np.exp(-x) + * (1.0 + (x / x_t) ** beta) + ) + + np.testing.assert_allclose(result, expected) + + +def test_double_schechter_zero_phi_star_warning() -> None: + """Tests that double_schechter warns when phi_star is zero.""" + with pytest.warns(UserWarning): + result = double_schechter( + [-20.0], + phi_star=0.0, + m_star=-20.0, + alpha=-1.0, + beta=1.0, + m_transition=-18.0, + ) + + np.testing.assert_allclose(result, np.array([0.0])) + + +def test_double_schechter_rejects_negative_phi_star() -> None: + """Tests that double_schechter rejects negative phi_star.""" + with pytest.raises(ValueError, match="phi_star must be non-negative"): + double_schechter( + [-20.0], + phi_star=-1e-3, + m_star=-20.0, + alpha=-1.0, + beta=1.0, + m_transition=-18.0, + ) + + +def test_double_schechter_rejects_nonfinite_beta() -> None: + """Tests that double_schechter rejects non-finite beta.""" + with pytest.raises(ValueError, match="beta must be finite"): + double_schechter( + [-20.0], + phi_star=1e-3, + m_star=-20.0, + alpha=-1.0, + beta=np.nan, + m_transition=-18.0, + ) + + +def test_schechter_cumulative_rejects_nonfinite_phi_star() -> None: + """Tests that schechter_cumulative rejects non-finite phi_star.""" + with pytest.raises(ValueError, match="phi_star must be finite"): + schechter_cumulative( + [-20.0], + phi_star=np.inf, + m_star=-20.0, + alpha=-0.5, + ) + + +def test_schechter_cumulative_rejects_negative_phi_star() -> None: + """Tests that schechter_cumulative rejects negative phi_star.""" + with pytest.raises(ValueError, match="phi_star must be non-negative"): + schechter_cumulative( + [-20.0], + phi_star=-1e-3, + m_star=-20.0, + alpha=-0.5, + ) + + +def test_schechter_cumulative_zero_phi_star_returns_zero() -> None: + """Tests that zero phi_star gives zero cumulative density.""" + result = schechter_cumulative( + [-20.0], + phi_star=0.0, + m_star=-20.0, + alpha=-0.5, + ) + + np.testing.assert_allclose(result, np.array([0.0])) + + +def test_schechter_cumulative_faint_plus_bright_equals_total_gamma() -> None: + """Tests that bright and faint cumulative branches sum to total density.""" + m_lim = np.array([-20.0, -19.0]) + phi_star = 1e-3 + alpha = -0.5 + + n_bright = schechter_cumulative( + m_lim, + phi_star=phi_star, + m_star=-20.0, + alpha=alpha, + brighter_than=True, + ) + n_faint = schechter_cumulative( + m_lim, + phi_star=phi_star, + m_star=-20.0, + alpha=alpha, + brighter_than=False, + ) + + expected_total = phi_star * np.sqrt(np.pi) + np.testing.assert_allclose(n_bright + n_faint, expected_total) + + +def test_schechter_cumulative_evolving_rejects_negative_phi_star() -> None: + """Tests that evolving cumulative rejects negative evolved phi_star.""" + with pytest.raises(ValueError, match="phi_star must be non-negative"): + schechter_cumulative_evolving( + [-20.0], + [0.5], + phi_model="constant", + phi_kwargs={"phi_star": -1e-3}, + m_star_model="constant", + m_star_kwargs={"m_star": -20.0}, + alpha_model="constant", + alpha_kwargs={"alpha": -0.5}, + ) + + +def test_schechter_cumulative_evolving_rejects_divergent_alpha() -> None: + """Tests that evolving cumulative rejects alpha <= -1.""" + with pytest.raises(ValueError, match="undefined where alpha <= -1"): + schechter_cumulative_evolving( + [-20.0], + [0.5], + phi_model="constant", + phi_kwargs={"phi_star": 1e-3}, + m_star_model="constant", + m_star_kwargs={"m_star": -20.0}, + alpha_model="constant", + alpha_kwargs={"alpha": -1.0}, + ) + + +def test_double_schechter_accepts_array_phi_star() -> None: + """Tests that double_schechter broadcasts array phi_star.""" + + result = double_schechter( + [-22.0, -21.0], + phi_star=np.array([1e-3, 2e-3]), + m_star=-20.0, + alpha=-1.0, + beta=1.0, + m_transition=-18.0, + ) + + assert result.shape == (2,) + assert np.all(result >= 0.0) diff --git a/tests/test_lumfuncs_parameter_models.py b/tests/test_lumfuncs_parameter_models.py index 98c035c7..0b26e07c 100644 --- a/tests/test_lumfuncs_parameter_models.py +++ b/tests/test_lumfuncs_parameter_models.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.photometry.lf_parameter_models.py``.""" +"""Unit tests for ``lfkit.photometry.lf_parameter_models``.""" from __future__ import annotations @@ -465,3 +465,46 @@ def test_evaluate_lf_parameters_rejects_non_finite_redshift() -> None: alpha_model="constant", alpha_kwargs={"alpha": -1.0}, ) + + +def test_linear_parameter_models_reject_non_finite_redshift() -> None: + """Tests that linear parameter models reject non-finite redshift values.""" + z = np.array([0.0, np.nan, 1.0]) + + with pytest.raises(ValueError, match="z contains NaN or infinite values"): + phi_star_linear_p(z, phi_0_star=1.0e-3, p=1.0) + + with pytest.raises(ValueError, match="z contains NaN or infinite values"): + m_star_linear_q(z, m_0_star=-20.0, q=1.0) + + with pytest.raises(ValueError, match="z contains NaN or infinite values"): + alpha_linear(z, alpha_0=-1.0, alpha_1=0.1) + + +def test_evaluate_lf_parameters_propagates_missing_model_kwargs() -> None: + """Tests that missing kwargs from selected models raise TypeError.""" + with pytest.raises(TypeError): + evaluate_lf_parameters( + np.array([0.0, 0.5]), + phi_model="linear_p", + phi_kwargs=None, + m_star_model="constant", + m_star_kwargs={"m_star": -20.0}, + alpha_model="constant", + alpha_kwargs={"alpha": -1.0}, + ) + + +def test_available_lf_parameter_models_includes_registered_custom_model() -> None: + """Tests that available model listing includes newly registered models.""" + + def custom_phi(z: np.ndarray) -> np.ndarray: + return np.ones_like(z, dtype=float) + + register_phi_star_model("zzz_custom_phi_test", custom_phi) + + try: + result = available_lf_parameter_models() + assert "zzz_custom_phi_test" in result["phi_star"] + finally: + PHI_STAR_MODELS.pop("zzz_custom_phi_test", None) diff --git a/tests/test_lumfuncs_redshift_density.py b/tests/test_lumfuncs_redshift_density.py index 661755e0..50706717 100644 --- a/tests/test_lumfuncs_redshift_density.py +++ b/tests/test_lumfuncs_redshift_density.py @@ -1,4 +1,4 @@ -"""Unit tests for the ``lfkit.photometry.lf_redshift_density.py`` module.""" +"""Unit tests for the ``lfkit.photometry.lf_redshift_density``.""" import numpy as np import pytest @@ -563,3 +563,70 @@ def test_optional_correction_array_rejects_unbroadcastable_array() -> None: np.array([0.1, 0.2]), name="k_correction", ) + + +def test_api_aliases_cover_public_exports() -> None: + """Tests that public redshift-density functions are included in API aliases.""" + missing_aliases = set(lfrd.__all__) - set(lfrd.__api_aliases__) + assert missing_aliases == set() + + +def test_lf_integrated_number_density_rejects_nonfinite_redshift() -> None: + """Tests that non-finite redshifts are rejected.""" + with pytest.raises(ValueError, match="z contains NaN or infinite values"): + lfrd.lf_integrated_number_density( + [0.1, np.nan], + constant_lf, + m_lim=M_LIM, + m_bright=M_BRIGHT, + luminosity_distance_mpc_fn=constant_luminosity_distance, + ) + + +def test_lf_weighted_redshift_density_rejects_nonfinite_redshift() -> None: + """Tests that weighted redshift density rejects non-finite redshifts.""" + with pytest.raises(ValueError, match="z contains NaN or infinite values"): + lfrd.lf_weighted_redshift_density( + [0.1, np.inf], + constant_lf, + m_lim=M_LIM, + m_bright=M_BRIGHT, + luminosity_distance_mpc_fn=constant_luminosity_distance, + volume_weight_fn=constant_volume_weight, + normalize=False, + ) + + +def test_optional_correction_array_accepts_column_broadcast() -> None: + """Tests that correction arrays broadcast to multidimensional redshift grids.""" + z = np.array([[0.1, 0.2], [0.3, 0.4]]) + + result = lfrd._optional_correction_array( + np.array([[0.5], [1.0]]), + z, + name="k_correction", + ) + + expected = np.array([[0.5, 0.5], [1.0, 1.0]]) + np.testing.assert_allclose(result, expected) + + +def test_lf_weighted_redshift_density_rejects_zero_volume_weight_normalization() -> None: + """Tests that normalization rejects zero volume-weight density.""" + + def zero_volume_weight(z: np.ndarray) -> np.ndarray: + return np.zeros_like(z, dtype=float) + + with pytest.raises( + ValueError, + match="Cannot normalize LF-weighted redshift density", + ): + lfrd.lf_weighted_redshift_density( + [0.0, 1.0], + constant_lf, + m_lim=M_LIM, + m_bright=M_BRIGHT, + luminosity_distance_mpc_fn=constant_luminosity_distance, + volume_weight_fn=zero_volume_weight, + normalize=True, + ) diff --git a/tests/test_lumfuncs_registry.py b/tests/test_lumfuncs_registry.py new file mode 100644 index 00000000..20abb234 --- /dev/null +++ b/tests/test_lumfuncs_registry.py @@ -0,0 +1,437 @@ +"""Unit tests for ``lfkit.luminosity_functions.registry``.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from lfkit.luminosity_functions import registry + + +def model_absolute_mag(absolute_mag: object) -> object: + """Fake absolute-magnitude luminosity function.""" + return absolute_mag + + +def model_with_redshift(absolute_mag: object, redshift: object) -> tuple[object, object]: + """Fake redshift-dependent luminosity function.""" + return absolute_mag, redshift + + +def model_with_z(absolute_mag: object, z: object) -> tuple[object, object]: + """Fake z-dependent luminosity function.""" + return absolute_mag, z + + +def model_with_condition( + luminosity: object, + condition: object, +) -> tuple[object, object]: + """Fake condition-dependent luminosity function.""" + return luminosity, condition + + +def model_with_x(magnitude: object, x: object) -> tuple[object, object]: + """Fake generic conditional luminosity function.""" + return magnitude, x + + +def invalid_first_argument(foo: object) -> object: + """Fake callable with unsupported first argument.""" + return foo + + +def no_arguments() -> None: + """Fake callable with no arguments.""" + return None + + +def schechter_from_m(magnitude: object) -> object: + """Fake apparent-magnitude evaluator.""" + return magnitude + + +def test_lf_model_defaults() -> None: + """Tests default ``LFModel`` metadata values.""" + model = registry.LFModel(name="test", function=model_absolute_mag) + + assert model.name == "test" + assert model.function is model_absolute_mag + assert model.independent_variable == "absolute_mag" + assert model.requires_z is False + + +def test_public_model_name_removes_conditional_prefix_and_lf_suffix() -> None: + """Tests conversion from implementation names to public names.""" + assert registry._public_model_name("conditional_schechter_lf") == "schechter" + assert registry._public_model_name("schechter_lf") == "schechter" + assert registry._public_model_name("conditional_gaussian") == "gaussian" + assert registry._public_model_name("double_schechter") == "double_schechter" + + +@pytest.mark.parametrize( + ("function", "expected"), + [ + (model_absolute_mag, False), + (model_with_redshift, True), + (model_with_z, True), + (model_with_condition, True), + (model_with_x, True), + ], +) +def test_requires_second_independent_variable( + function: object, + expected: bool, +) -> None: + """Tests supported second independent variable names.""" + import inspect + + assert registry._requires_second_independent_variable( + inspect.signature(function) + ) is expected + + +def test_register_lf_model_adds_valid_model() -> None: + """Tests registration of a valid luminosity function model.""" + lf_models: dict[str, registry.LFModel] = {} + from_m_models: dict[str, object] = {} + + registry._register_lf_model( + "schechter_lf", + model_absolute_mag, + lf_models=lf_models, + from_m_models=from_m_models, + name_transform=registry._public_model_name, + ) + + assert tuple(lf_models) == ("schechter",) + assert lf_models["schechter"].name == "schechter" + assert lf_models["schechter"].function is model_absolute_mag + assert lf_models["schechter"].independent_variable == "absolute_mag" + assert lf_models["schechter"].requires_z is False + assert from_m_models == {} + + +def test_register_lf_model_records_requires_z() -> None: + """Tests that redshift-dependent models are marked as requiring z.""" + lf_models: dict[str, registry.LFModel] = {} + + registry._register_lf_model( + "evolving_schechter", + model_with_redshift, + lf_models=lf_models, + from_m_models=None, + name_transform=None, + ) + + assert lf_models["evolving_schechter"].requires_z is True + + +def test_register_lf_model_accepts_magnitude_and_luminosity_first_arguments() -> None: + """Tests supported first independent variable names.""" + lf_models: dict[str, registry.LFModel] = {} + + registry._register_lf_model( + "magnitude_model", + model_with_x, + lf_models=lf_models, + from_m_models=None, + name_transform=None, + ) + registry._register_lf_model( + "luminosity_model", + model_with_condition, + lf_models=lf_models, + from_m_models=None, + name_transform=None, + ) + + assert lf_models["magnitude_model"].independent_variable == "magnitude" + assert lf_models["luminosity_model"].independent_variable == "luminosity" + + +def test_register_lf_model_ignores_non_callable() -> None: + """Tests that non-callable objects are ignored.""" + lf_models: dict[str, registry.LFModel] = {} + + registry._register_lf_model( + "not_callable", + 1, # type: ignore[arg-type] + lf_models=lf_models, + from_m_models=None, + name_transform=None, + ) + + assert lf_models == {} + + +def test_register_lf_model_ignores_callable_without_parameters() -> None: + """Tests that callables without parameters are ignored.""" + lf_models: dict[str, registry.LFModel] = {} + + registry._register_lf_model( + "no_arguments", + no_arguments, + lf_models=lf_models, + from_m_models=None, + name_transform=None, + ) + + assert lf_models == {} + + +def test_register_lf_model_ignores_unsupported_first_argument() -> None: + """Tests that callables with unsupported first arguments are ignored.""" + lf_models: dict[str, registry.LFModel] = {} + + registry._register_lf_model( + "bad_model", + invalid_first_argument, + lf_models=lf_models, + from_m_models=None, + name_transform=None, + ) + + assert lf_models == {} + + +def test_register_lf_model_registers_from_m_model_only() -> None: + """Tests that ``*_from_m`` callables go into the apparent-magnitude registry.""" + lf_models: dict[str, registry.LFModel] = {} + from_m_models: dict[str, object] = {} + + registry._register_lf_model( + "schechter_from_m", + schechter_from_m, + lf_models=lf_models, + from_m_models=from_m_models, + name_transform=registry._public_model_name, + ) + + assert lf_models == {} + assert from_m_models == {"schechter": schechter_from_m} + + +def test_register_lf_model_ignores_from_m_when_registry_is_none() -> None: + """Tests that ``*_from_m`` callables are ignored without a target registry.""" + lf_models: dict[str, registry.LFModel] = {} + + registry._register_lf_model( + "schechter_from_m", + schechter_from_m, + lf_models=lf_models, + from_m_models=None, + name_transform=None, + ) + + assert lf_models == {} + + +def test_register_module_lf_models_uses_all(monkeypatch: pytest.MonkeyPatch) -> None: + """Tests registration from a module ``__all__`` declaration.""" + fake_module = SimpleNamespace( + __all__=["schechter_lf", "CONSTANT"], + schechter_lf=model_absolute_mag, + CONSTANT=1.0, + ) + lf_models: dict[str, registry.LFModel] = {} + + registry._register_module_lf_models( + fake_module, + lf_models=lf_models, + from_m_models=None, + name_transform=registry._public_model_name, + ) + + assert tuple(lf_models) == ("schechter",) + assert lf_models["schechter"].function is model_absolute_mag + + +def test_discover_models_package_uses_iter_model_functions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests discovery from the models package helper.""" + monkeypatch.setattr( + registry, + "iter_model_functions", + lambda: { + "schechter_lf": model_absolute_mag, + "schechter_from_m": schechter_from_m, + "bad_model": invalid_first_argument, + }, + ) + + lf_models: dict[str, registry.LFModel] = {} + from_m_models: dict[str, object] = {} + + registry._discover_models_package(lf_models, from_m_models) + + assert tuple(lf_models) == ("schechter",) + assert lf_models["schechter"].function is model_absolute_mag + assert from_m_models == {"schechter": schechter_from_m} + + +def test_discover_conditional_models_uses_conditional_module( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests discovery from ``conditional_models``.""" + fake_module = SimpleNamespace( + __all__=["conditional_schechter_lf"], + conditional_schechter_lf=model_with_condition, + ) + + monkeypatch.setattr(registry, "conditional_models", fake_module) + + conditional_lf_models: dict[str, registry.LFModel] = {} + + registry._discover_conditional_models(conditional_lf_models) + + assert tuple(conditional_lf_models) == ("schechter",) + assert conditional_lf_models["schechter"].function is model_with_condition + assert conditional_lf_models["schechter"].requires_z is True + + +def test_discover_lf_models_returns_all_three_registries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests full registry discovery.""" + fake_conditional_module = SimpleNamespace( + __all__=["conditional_schechter_lf"], + conditional_schechter_lf=model_with_condition, + ) + + monkeypatch.setattr( + registry, + "iter_model_functions", + lambda: { + "schechter_lf": model_absolute_mag, + "schechter_from_m": schechter_from_m, + }, + ) + monkeypatch.setattr(registry, "conditional_models", fake_conditional_module) + + lf_models, conditional_lf_models, from_m_models = registry.discover_lf_models() + + assert tuple(lf_models) == ("schechter",) + assert tuple(conditional_lf_models) == ("schechter",) + assert from_m_models == {"schechter": schechter_from_m} + + +def test_available_lf_models_are_sorted(monkeypatch: pytest.MonkeyPatch) -> None: + """Tests that available LF model names are sorted.""" + monkeypatch.setattr( + registry, + "LF_MODELS", + { + "schechter": registry.LFModel("schechter", model_absolute_mag), + "gaussian": registry.LFModel("gaussian", model_absolute_mag), + }, + ) + + assert registry.available_lf_models() == ("gaussian", "schechter") + + +def test_available_conditional_lf_models_are_sorted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests that available conditional LF model names are sorted.""" + monkeypatch.setattr( + registry, + "CONDITIONAL_LF_MODELS", + { + "schechter": registry.LFModel("schechter", model_absolute_mag), + "gaussian": registry.LFModel("gaussian", model_absolute_mag), + }, + ) + + assert registry.available_conditional_lf_models() == ("gaussian", "schechter") + + +def test_available_lf_from_m_models_are_sorted(monkeypatch: pytest.MonkeyPatch) -> None: + """Tests that available apparent-magnitude model names are sorted.""" + monkeypatch.setattr( + registry, + "LF_FROM_M_MODELS", + { + "schechter": schechter_from_m, + "gaussian": schechter_from_m, + }, + ) + + assert registry.available_lf_from_m_models() == ("gaussian", "schechter") + + +def test_get_lf_model_returns_registered_model(monkeypatch: pytest.MonkeyPatch) -> None: + """Tests successful LF model lookup.""" + expected = registry.LFModel("schechter", model_absolute_mag) + + monkeypatch.setattr(registry, "LF_MODELS", {"schechter": expected}) + + assert registry.get_lf_model("schechter") is expected + + +def test_get_conditional_lf_model_returns_registered_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests successful conditional LF model lookup.""" + expected = registry.LFModel("schechter", model_with_condition) + + monkeypatch.setattr(registry, "CONDITIONAL_LF_MODELS", {"schechter": expected}) + + assert registry.get_conditional_lf_model("schechter") is expected + + +def test_get_lf_from_m_model_returns_registered_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests successful apparent-magnitude evaluator lookup.""" + monkeypatch.setattr(registry, "LF_FROM_M_MODELS", {"schechter": schechter_from_m}) + + assert registry.get_lf_from_m_model("schechter") is schechter_from_m + + +def test_get_lf_model_raises_for_unknown_name(monkeypatch: pytest.MonkeyPatch) -> None: + """Tests LF model lookup error message.""" + monkeypatch.setattr( + registry, + "LF_MODELS", + {"schechter": registry.LFModel("schechter", model_absolute_mag)}, + ) + + with pytest.raises(ValueError, match="Unknown luminosity function model 'bad'"): + registry.get_lf_model("bad") + + +def test_get_conditional_lf_model_raises_for_unknown_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests conditional LF model lookup error message.""" + monkeypatch.setattr( + registry, + "CONDITIONAL_LF_MODELS", + {"schechter": registry.LFModel("schechter", model_with_condition)}, + ) + + with pytest.raises( + ValueError, + match="Unknown conditional luminosity function model 'bad'", + ): + registry.get_conditional_lf_model("bad") + + +def test_get_lf_from_m_model_raises_for_unknown_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests apparent-magnitude evaluator lookup error message.""" + monkeypatch.setattr( + registry, + "LF_FROM_M_MODELS", + {"schechter": schechter_from_m}, + ) + + with pytest.raises( + ValueError, + match="phi_from_m is not defined for luminosity function model 'bad'", + ): + registry.get_lf_from_m_model("bad") diff --git a/tests/test_photometry_luminosities.py b/tests/test_photometry_luminosities.py index 38398c27..8e031c3b 100644 --- a/tests/test_photometry_luminosities.py +++ b/tests/test_photometry_luminosities.py @@ -1,13 +1,13 @@ -"""Unit tests for ``lfkit.photometry.luminosity_function.py``.""" +"""Unit tests for ``lfkit.photometry.luminosities``.""" from __future__ import annotations import numpy as np import pytest -from scipy.special import gamma, gammaincc from lfkit.photometry.luminosities import ( luminosity_from_magnitude, + luminosity_ratio, luminosity_ratio_from_magnitudes, luminosity_weight_from_magnitude, magnitude_difference_from_luminosity_ratio, @@ -27,6 +27,85 @@ def test_luminosity_ratio_matches_known_magnitude_difference() -> None: np.testing.assert_allclose(result, 10.0) +def test_luminosity_ratio_function_matches_ratio_from_magnitudes() -> None: + """Tests that luminosity_ratio matches luminosity_ratio_from_magnitudes.""" + absolute_mag = np.array([-22.0, -21.0, -20.0]) + m_star = -21.0 + + result = luminosity_ratio(absolute_mag, m_star) + expected = luminosity_ratio_from_magnitudes(absolute_mag, m_star) + + np.testing.assert_allclose(result, expected) + + +def test_luminosity_ratio_accepts_broadcastable_inputs() -> None: + """Tests that luminosity_ratio accepts broadcastable magnitude inputs.""" + absolute_mag = np.array([-22.0, -21.0, -20.0]) + m_star = np.array([[-21.0], [-20.0]]) + + result = luminosity_ratio(absolute_mag, m_star) + + assert result.shape == (2, 3) + np.testing.assert_allclose( + result, + 10.0 ** (-0.4 * (absolute_mag - m_star)), + ) + + +def test_luminosity_ratio_returns_scalar_array_for_scalar_inputs() -> None: + """Tests that luminosity_ratio returns a scalar NumPy array for scalars.""" + result = luminosity_ratio(-21.0, -21.0) + + assert isinstance(result, np.ndarray) + assert result.shape == () + assert result.dtype == float + np.testing.assert_allclose(result, 1.0) + + +def test_luminosity_ratio_clips_extreme_values() -> None: + """Tests that luminosity_ratio clips extreme values to finite bounds.""" + result = luminosity_ratio( + absolute_mag=np.array([-2000.0, 2000.0]), + m_star=0.0, + ) + + np.testing.assert_allclose(result, np.array([1e300, 1e-300])) + + +def test_luminosity_ratio_from_magnitudes_accepts_broadcastable_inputs() -> None: + """Tests that luminosity_ratio_from_magnitudes broadcasts inputs.""" + magnitude = np.array([-22.0, -21.0, -20.0]) + ref_magnitude = np.array([[-21.0], [-20.0]]) + + result = luminosity_ratio_from_magnitudes(magnitude, ref_magnitude) + + assert result.shape == (2, 3) + np.testing.assert_allclose( + result, + 10.0 ** (-0.4 * (magnitude - ref_magnitude)), + ) + + +def test_luminosity_ratio_from_magnitudes_returns_scalar_array() -> None: + """Tests that luminosity_ratio_from_magnitudes returns scalar arrays.""" + result = luminosity_ratio_from_magnitudes(-20.0, -20.0) + + assert isinstance(result, np.ndarray) + assert result.shape == () + assert result.dtype == float + np.testing.assert_allclose(result, 1.0) + + +def test_luminosity_ratio_from_magnitudes_clips_extreme_values() -> None: + """Tests that luminosity_ratio_from_magnitudes clips extreme values.""" + result = luminosity_ratio_from_magnitudes( + magnitude=np.array([-2000.0, 2000.0]), + ref_magnitude=0.0, + ) + + np.testing.assert_allclose(result, np.array([1e300, 1e-300])) + + def test_magnitude_difference_inverts_luminosity_ratio() -> None: """Tests that magnitude differences invert luminosity ratios.""" ratios = np.array([0.1, 1.0, 10.0, 25.0]) @@ -35,6 +114,16 @@ def test_magnitude_difference_inverts_luminosity_ratio() -> None: np.testing.assert_allclose(reconstructed, ratios) +def test_magnitude_difference_returns_scalar_array_for_scalar_input() -> None: + """Tests that magnitude_difference_from_luminosity_ratio returns scalar arrays.""" + result = magnitude_difference_from_luminosity_ratio(10.0) + + assert isinstance(result, np.ndarray) + assert result.shape == () + assert result.dtype == float + np.testing.assert_allclose(result, -2.5) + + def test_magnitude_difference_raises_for_non_positive_ratio() -> None: """Tests that non-positive luminosity ratios raise ValueError.""" with pytest.raises(ValueError, match="strictly positive"): @@ -62,6 +151,23 @@ def test_luminosity_weight_respects_reference_magnitude() -> None: np.testing.assert_allclose(result, expected) +def test_luminosity_weight_returns_scalar_array_for_scalar_input() -> None: + """Tests that luminosity_weight_from_magnitude returns scalar arrays.""" + result = luminosity_weight_from_magnitude(0.0) + + assert isinstance(result, np.ndarray) + assert result.shape == () + assert result.dtype == float + np.testing.assert_allclose(result, 1.0) + + +def test_luminosity_weight_clips_extreme_values() -> None: + """Tests that luminosity_weight_from_magnitude clips extreme values.""" + result = luminosity_weight_from_magnitude(np.array([-2000.0, 2000.0])) + + np.testing.assert_allclose(result, np.array([1e300, 1e-300])) + + def test_luminosity_from_magnitude_scales_reference_luminosity() -> None: """Tests that luminosity scales with the supplied reference luminosity.""" result = luminosity_from_magnitude( @@ -69,7 +175,33 @@ def test_luminosity_from_magnitude_scales_reference_luminosity() -> None: reference_magnitude=-20.0, reference_luminosity=3.0, ) - expected = 3.0 * np.array([10.0 ** 0.4, 1.0]) + expected = 3.0 * np.array([10.0**0.4, 1.0]) + np.testing.assert_allclose(result, expected) + + +def test_luminosity_from_magnitude_returns_scalar_array_for_scalar_input() -> None: + """Tests that luminosity_from_magnitude returns scalar arrays.""" + result = luminosity_from_magnitude( + magnitude=-20.0, + reference_magnitude=-20.0, + reference_luminosity=3.0, + ) + + assert isinstance(result, np.ndarray) + assert result.shape == () + assert result.dtype == float + np.testing.assert_allclose(result, 3.0) + + +def test_luminosity_from_magnitude_uses_reference_magnitude() -> None: + """Tests that luminosity_from_magnitude uses the reference magnitude.""" + result = luminosity_from_magnitude( + magnitude=np.array([0.0, 2.5]), + reference_magnitude=2.5, + reference_luminosity=4.0, + ) + + expected = np.array([40.0, 4.0]) np.testing.assert_allclose(result, expected) @@ -80,3 +212,63 @@ 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_magnitude_difference_matches_known_ratios() -> None: + """Tests that known luminosity ratios map to known magnitude differences.""" + ratios = np.array([0.1, 1.0, 10.0]) + expected = np.array([2.5, -0.0, -2.5]) + + result = magnitude_difference_from_luminosity_ratio(ratios) + + np.testing.assert_allclose(result, expected) + + +def test_luminosity_from_magnitude_matches_weight_times_reference_luminosity() -> None: + """Tests that luminosity_from_magnitude matches weight scaled by reference luminosity.""" + magnitude = np.array([-22.0, -21.0, -20.0]) + reference_magnitude = -21.0 + reference_luminosity = 5.0 + + result = luminosity_from_magnitude( + magnitude, + reference_magnitude=reference_magnitude, + reference_luminosity=reference_luminosity, + ) + expected = reference_luminosity * luminosity_weight_from_magnitude( + magnitude, + reference_magnitude=reference_magnitude, + ) + + np.testing.assert_allclose(result, expected) + + +def test_luminosity_from_magnitude_accepts_broadcastable_inputs() -> None: + """Tests that luminosity_from_magnitude accepts broadcastable magnitude inputs.""" + magnitude = np.array([-22.0, -21.0, -20.0]) + reference_magnitude = np.array([[-21.0], [-20.0]]) + + result = luminosity_from_magnitude( + magnitude, + reference_magnitude=reference_magnitude, + reference_luminosity=2.0, + ) + + assert result.shape == (2, 3) + np.testing.assert_allclose( + result, + 2.0 * 10.0 ** (-0.4 * (magnitude - reference_magnitude)), + ) + + +def test_luminosity_outputs_are_finite_for_extreme_magnitudes() -> None: + """Tests that luminosity helpers return finite values for extreme magnitudes.""" + magnitudes = np.array([-2000.0, 0.0, 2000.0]) + + ratio = luminosity_ratio_from_magnitudes(magnitudes, 0.0) + weight = luminosity_weight_from_magnitude(magnitudes) + luminosity = luminosity_from_magnitude(magnitudes) + + assert np.all(np.isfinite(ratio)) + assert np.all(np.isfinite(weight)) + assert np.all(np.isfinite(luminosity)) diff --git a/tests/test_photometry_magnitudes.py b/tests/test_photometry_magnitudes.py index 28b5ea85..ffb44668 100644 --- a/tests/test_photometry_magnitudes.py +++ b/tests/test_photometry_magnitudes.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.photometry.magnitudes.py``.""" +"""Unit tests for ``lfkit.photometry.magnitudes``.""" from __future__ import annotations @@ -550,3 +550,132 @@ def test_apparent_magnitude_from_luminosity_distance_rejects_nonfinite_distance( -4.0, np.nan, ) + + +def test_total_magnitude_correction_preserves_broadcast_shape() -> None: + """Tests that total_magnitude_correction returns the broadcasted output shape.""" + out = total_magnitude_correction( + k_correction=np.array([[0.1], [0.2]]), + e_correction=np.array([0.0, 0.1, 0.2]), + ) + + expected = np.array( + [ + [0.1, 0.0, -0.1], + [0.2, 0.1, 0.0], + ], + ) + assert out.shape == (2, 3) + np.testing.assert_allclose(out, expected) + + +def test_absolute_magnitude_from_luminosity_distance_returns_float64_array() -> None: + """Tests that apparent-to-absolute conversion returns a float64 array.""" + out = absolute_magnitude_from_luminosity_distance( + np.array([26, 27]), + np.array([10, 100]), + ) + + assert isinstance(out, np.ndarray) + assert out.dtype == np.float64 + + +def test_apparent_magnitude_from_luminosity_distance_returns_float64_array() -> None: + """Tests that absolute-to-apparent conversion returns a float64 array.""" + out = apparent_magnitude_from_luminosity_distance( + np.array([-4, -8]), + np.array([10, 100]), + ) + + assert isinstance(out, np.ndarray) + assert out.dtype == np.float64 + + +def test_luminosity_distance_formula_matches_known_distance_modulus() -> None: + """Tests that luminosity-distance conversions use mu = 5 log10(dL) + 25.""" + result = absolute_magnitude_from_luminosity_distance( + apparent_mag=30.0, + luminosity_distance_mpc=100.0, + ) + + assert result == pytest.approx(-5.0) + + +def test_apparent_magnitude_from_luminosity_distance_matches_known_distance_modulus() -> None: + """Tests that absolute-to-apparent conversion uses the expected distance modulus.""" + result = apparent_magnitude_from_luminosity_distance( + absolute_mag=0.0, + luminosity_distance_mpc=100.0, + ) + + assert result == pytest.approx(35.0) + + +def test_absolute_magnitude_supports_scalar_magnitude_with_array_redshift( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests that absolute_magnitude broadcasts scalar magnitude over redshift.""" + + def mock_distance_modulus( + cosmo_obj: object, + z: np.ndarray, + h: float | None = None, + ) -> np.ndarray: + """Return a redshift-shaped distance modulus.""" + _, _ = cosmo_obj, h + return np.asarray([40.0, 41.0, 42.0], dtype=float) + + monkeypatch.setattr(magnitudes, "distance_modulus", mock_distance_modulus) + + out = magnitudes.absolute_magnitude( + cosmo_obj=object(), + z=np.array([0.1, 0.2, 0.3]), + apparent_mag=22.0, + ) + + np.testing.assert_allclose(out, np.array([-18.0, -19.0, -20.0])) + + +def test_apparent_magnitude_supports_scalar_magnitude_with_array_redshift( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tests that apparent_magnitude broadcasts scalar magnitude over redshift.""" + + def mock_distance_modulus( + cosmo_obj: object, + z: np.ndarray, + h: float | None = None, + ) -> np.ndarray: + """Return a redshift-shaped distance modulus.""" + _, _ = cosmo_obj, h + return np.asarray([40.0, 41.0, 42.0], dtype=float) + + monkeypatch.setattr(magnitudes, "distance_modulus", mock_distance_modulus) + + out = magnitudes.apparent_magnitude( + cosmo_obj=object(), + z=np.array([0.1, 0.2, 0.3]), + absolute_mag=-20.0, + ) + + np.testing.assert_allclose(out, np.array([20.0, 21.0, 22.0])) + + +def test_magnitudes_exports_expected_public_names() -> None: + """Tests that magnitudes exposes the expected public API names.""" + expected = { + "total_magnitude_correction", + "absolute_magnitude", + "absolute_magnitude_from_luminosity_distance", + "apparent_magnitude", + "apparent_magnitude_from_luminosity_distance", + } + + assert set(magnitudes.__all__) == expected + + +def test_magnitudes_api_aliases_match_public_functions() -> None: + """Tests that magnitudes API aliases point to public functions.""" + for public_name in magnitudes.__api_aliases__: + assert public_name in magnitudes.__all__ + assert callable(getattr(magnitudes, public_name)) diff --git a/tests/test_utils_download_pogg97_data.py b/tests/test_utils_download_pogg97_data.py index 65446a97..429d78fb 100644 --- a/tests/test_utils_download_pogg97_data.py +++ b/tests/test_utils_download_pogg97_data.py @@ -1,4 +1,4 @@ -"""Tests for the ``lfkit.utils.download_poggianti97_data.py``.""" +"""Tests for the ``lfkit.utils.download_poggianti97_data``.""" from __future__ import annotations diff --git a/tests/test_utils_evaluators.py b/tests/test_utils_evaluators.py index f0f9ae83..5f09bafc 100644 --- a/tests/test_utils_evaluators.py +++ b/tests/test_utils_evaluators.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.utils.evaluators.py``.""" +"""Unit tests for ``lfkit.utils.evaluators``.""" from __future__ import annotations @@ -672,3 +672,178 @@ def weight_fn(m_abs: np.ndarray, z: np.ndarray) -> np.ndarray: m_grid=m_grid, z_grid=z_grid, ) + + +def test_evaluators_exports_expected_public_names() -> None: + """Tests that evaluators exposes the expected public API names.""" + import lfkit.utils.evaluators as evaluators + + expected = { + "evaluate_non_negative_redshift_callable", + "evaluate_optional_redshift_callable", + "evaluate_positive_redshift_callable", + "evaluate_weight_on_grid", + "evaluate_lf_on_grid", + } + + assert set(evaluators.__all__) == expected + + +def test_evaluate_positive_redshift_callable_converts_list_output() -> None: + """Tests that positive callable list output is converted to a float array.""" + z = np.array([0.0, 0.5, 1.0], dtype=float) + + def fn(z_arr: np.ndarray) -> list[float]: + """Return positive values as a list.""" + _ = z_arr + return [1.0, 2.0, 3.0] + + result = evaluate_positive_redshift_callable( + fn, + z, + name="distance", + ) + + assert result.dtype == np.float64 + np.testing.assert_allclose(result, np.array([1.0, 2.0, 3.0])) + + +def test_evaluate_non_negative_redshift_callable_converts_list_output() -> None: + """Tests that non-negative callable list output is converted to a float array.""" + z = np.array([0.0, 0.5, 1.0], dtype=float) + + def fn(z_arr: np.ndarray) -> list[float]: + """Return non-negative values as a list.""" + _ = z_arr + return [0.0, 1.0, 2.0] + + result = evaluate_non_negative_redshift_callable( + fn, + z, + name="weight", + ) + + assert result.dtype == np.float64 + np.testing.assert_allclose(result, np.array([0.0, 1.0, 2.0])) + + +def test_evaluate_lf_on_grid_accepts_zero_values() -> None: + """Tests that LF grid evaluation accepts zero values.""" + m_grid = np.ones((2, 3), dtype=float) + z_grid = np.ones((2, 3), dtype=float) + + def lf(m_abs: np.ndarray, z: np.ndarray) -> np.ndarray: + """Return zero LF values.""" + _ = z + return np.zeros_like(m_abs, dtype=float) + + result = evaluate_lf_on_grid( + lf, + m_grid=m_grid, + z_grid=z_grid, + ) + + assert result.dtype == np.float64 + np.testing.assert_allclose(result, np.zeros_like(m_grid)) + + +def test_evaluate_weight_on_grid_accepts_zero_values() -> None: + """Tests that weight grid evaluation accepts zero values.""" + m_grid = np.ones((2, 3), dtype=float) + z_grid = np.ones((2, 3), dtype=float) + + def weight_fn(m_abs: np.ndarray, z: np.ndarray) -> np.ndarray: + """Return zero weight values.""" + _ = z + return np.zeros_like(m_abs, dtype=float) + + result = evaluate_weight_on_grid( + weight_fn, + m_grid=m_grid, + z_grid=z_grid, + ) + + assert result.dtype == np.float64 + np.testing.assert_allclose(result, np.zeros_like(m_grid)) + + +def test_evaluate_lf_on_grid_converts_list_output() -> None: + """Tests that LF grid list output is converted to a float array.""" + m_grid = np.ones((2, 2), dtype=float) + z_grid = np.ones((2, 2), dtype=float) + + def lf(m_abs: np.ndarray, z: np.ndarray) -> list[list[float]]: + """Return LF values as nested lists.""" + _ = m_abs + _ = z + return [[1.0, 2.0], [3.0, 4.0]] + + result = evaluate_lf_on_grid( + lf, + m_grid=m_grid, + z_grid=z_grid, + ) + + assert result.dtype == np.float64 + np.testing.assert_allclose(result, np.array([[1.0, 2.0], [3.0, 4.0]])) + + +def test_evaluate_weight_on_grid_converts_list_output() -> None: + """Tests that weight grid list output is converted to a float array.""" + m_grid = np.ones((2, 2), dtype=float) + z_grid = np.ones((2, 2), dtype=float) + + def weight_fn(m_abs: np.ndarray, z: np.ndarray) -> list[list[float]]: + """Return weight values as nested lists.""" + _ = m_abs + _ = z + return [[0.1, 0.2], [0.3, 0.4]] + + result = evaluate_weight_on_grid( + weight_fn, + m_grid=m_grid, + z_grid=z_grid, + ) + + assert result.dtype == np.float64 + np.testing.assert_allclose(result, np.array([[0.1, 0.2], [0.3, 0.4]])) + + +def test_evaluate_lf_on_grid_passes_grid_arguments() -> None: + """Tests that LF grid evaluation passes the magnitude and redshift grids.""" + m_grid = np.array([[-24.0, -23.0]], dtype=float) + z_grid = np.array([[0.1, 0.2]], dtype=float) + + def lf(m_abs: np.ndarray, z: np.ndarray) -> np.ndarray: + """Return values depending on both grid arguments.""" + np.testing.assert_allclose(m_abs, m_grid) + np.testing.assert_allclose(z, z_grid) + return np.ones_like(m_abs, dtype=float) + + result = evaluate_lf_on_grid( + lf, + m_grid=m_grid, + z_grid=z_grid, + ) + + np.testing.assert_allclose(result, np.ones_like(m_grid)) + + +def test_evaluate_weight_on_grid_passes_grid_arguments() -> None: + """Tests that weight grid evaluation passes the magnitude and redshift grids.""" + m_grid = np.array([[-24.0, -23.0]], dtype=float) + z_grid = np.array([[0.1, 0.2]], dtype=float) + + def weight_fn(m_abs: np.ndarray, z: np.ndarray) -> np.ndarray: + """Return values depending on both grid arguments.""" + np.testing.assert_allclose(m_abs, m_grid) + np.testing.assert_allclose(z, z_grid) + return np.ones_like(m_abs, dtype=float) + + result = evaluate_weight_on_grid( + weight_fn, + m_grid=m_grid, + z_grid=z_grid, + ) + + np.testing.assert_allclose(result, np.ones_like(m_grid)) diff --git a/tests/test_utils_integrators.py b/tests/test_utils_integrators.py index 9897b5da..635e39f9 100644 --- a/tests/test_utils_integrators.py +++ b/tests/test_utils_integrators.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.utils.integrators.py``.""" +"""Unit tests for ``lfkit.utils.integrators``.""" from __future__ import annotations @@ -8,6 +8,7 @@ from lfkit.utils.integrators import ( integrate_between_variable_bounds, safe_divide, + safe_power10, ) @@ -360,3 +361,192 @@ def test_safe_divide_rejects_unbroadcastable_inputs() -> None: np.ones((2, 3), dtype=float), np.ones((4,), dtype=float), ) + + +def test_integrators_exports_expected_public_names() -> None: + """Tests that integrators exposes the expected public API names.""" + import lfkit.utils.integrators as integrators + + expected = { + "integrate_between_variable_bounds", + "safe_divide", + "safe_power10", + } + + assert set(integrators.__all__) == expected + + +def test_integrate_between_variable_bounds_returns_float64_array() -> None: + """Tests that variable-bound integration returns a float64 array.""" + result = integrate_between_variable_bounds( + [0.1, 0.2], + lower=0, + upper=2, + integrand_fn=constant_integrand, + n_grid=64, + ) + + assert isinstance(result, np.ndarray) + assert result.dtype == np.float64 + + +def test_integrate_between_variable_bounds_uses_custom_error_names() -> None: + """Tests that variable-bound integration uses custom names in validation errors.""" + with pytest.raises(ValueError, match="m_min contains NaN or infinite values"): + integrate_between_variable_bounds( + [0.1, 0.2], + lower=np.nan, + upper=1.0, + integrand_fn=constant_integrand, + lower_name="m_min", + ) + + +def test_integrate_between_variable_bounds_uses_custom_grid_name() -> None: + """Tests that variable-bound integration uses the custom grid name in errors.""" + with pytest.raises(ValueError, match="n_points must be at least 2"): + integrate_between_variable_bounds( + [0.1, 0.2], + lower=0.0, + upper=1.0, + integrand_fn=constant_integrand, + n_grid=1, + n_grid_name="n_points", + ) + + +def test_integrate_between_variable_bounds_passes_expected_grid_shapes() -> None: + """Tests that variable-bound integration passes expected grids to integrand.""" + y = np.array([1.0, 2.0], dtype=float) + lower = np.array([0.0, 1.0], dtype=float) + upper = np.array([2.0, 4.0], dtype=float) + + def integrand_fn(x: np.ndarray, y_grid: np.ndarray) -> np.ndarray: + """Check integration-grid inputs and return constant values.""" + assert x.shape == (5, 2) + assert y_grid.shape == (5, 2) + np.testing.assert_allclose(x[0], lower) + np.testing.assert_allclose(x[-1], upper) + np.testing.assert_allclose(y_grid[0], y) + np.testing.assert_allclose(y_grid[-1], y) + return np.ones_like(x, dtype=float) + + result = integrate_between_variable_bounds( + y, + lower=lower, + upper=upper, + integrand_fn=integrand_fn, + n_grid=5, + ) + + np.testing.assert_allclose(result, np.array([2.0, 3.0])) + + +def test_integrate_between_variable_bounds_converts_list_integrand_output() -> None: + """Tests that variable-bound integration converts list output to a float array.""" + + def integrand_fn(x: np.ndarray, y: np.ndarray) -> list[list[float]]: + """Return list values broadcastable to the integration grid.""" + _ = x + _ = y + return [[1.0, 2.0]] + + result = integrate_between_variable_bounds( + [0.1, 0.2], + lower=0.0, + upper=2.0, + integrand_fn=integrand_fn, + n_grid=4, + ) + + np.testing.assert_allclose(result, np.array([2.0, 4.0])) + + +def test_safe_divide_uses_custom_fill_value() -> None: + """Tests that safe division uses the requested fill value.""" + result = safe_divide( + np.array([1.0, 2.0, 3.0]), + np.array([1.0, 0.0, -1.0]), + fill_value=-99.0, + ) + + np.testing.assert_allclose(result, np.array([1.0, -99.0, -99.0])) + + +def test_safe_divide_replaces_nonfinite_results() -> None: + """Tests that safe division replaces non-finite results with fill value.""" + result = safe_divide( + np.array([np.inf, 2.0, np.nan]), + np.array([1.0, 1.0, 1.0]), + fill_value=-1.0, + ) + + np.testing.assert_allclose(result, np.array([-1.0, 2.0, -1.0])) + + +def test_safe_divide_returns_float64_array() -> None: + """Tests that safe division returns a float64 array.""" + result = safe_divide( + np.array([1, 2, 3]), + np.array([1, 2, 3]), + ) + + assert isinstance(result, np.ndarray) + assert result.dtype == np.float64 + + +def test_safe_power10_matches_base_ten_power() -> None: + """Tests that safe_power10 matches ordinary base-ten powers.""" + result = safe_power10(np.array([-1.0, 0.0, 2.0])) + + np.testing.assert_allclose(result, np.array([0.1, 1.0, 100.0])) + + +def test_safe_power10_clips_large_positive_exponents() -> None: + """Tests that safe_power10 clips large positive exponents.""" + result = safe_power10( + np.array([1.0, 5.0]), + min_exponent=-2.0, + max_exponent=2.0, + ) + + np.testing.assert_allclose(result, np.array([10.0, 100.0])) + + +def test_safe_power10_clips_large_negative_exponents() -> None: + """Tests that safe_power10 clips large negative exponents.""" + result = safe_power10( + np.array([-5.0, -1.0]), + min_exponent=-2.0, + max_exponent=2.0, + ) + + np.testing.assert_allclose(result, np.array([0.01, 0.1])) + + +def test_safe_power10_supports_scalar_input() -> None: + """Tests that safe_power10 supports scalar input.""" + result = safe_power10(2.0) + + assert result.shape == () + assert result == pytest.approx(100.0) + + +def test_safe_power10_returns_float64_array() -> None: + """Tests that safe_power10 returns a float64 array.""" + result = safe_power10(np.array([0, 1, 2])) + + assert isinstance(result, np.ndarray) + assert result.dtype == np.float64 + + +def test_safe_power10_rejects_nan_exponent() -> None: + """Tests that safe_power10 rejects NaN exponents.""" + with pytest.raises(ValueError, match="exponent contains NaN"): + safe_power10(np.nan) + + +def test_safe_power10_rejects_infinite_exponent() -> None: + """Tests that safe_power10 rejects infinite exponents.""" + with pytest.raises(ValueError, match="exponent contains NaN or infinite values"): + safe_power10(np.inf) diff --git a/tests/test_utils_interpolation.py b/tests/test_utils_interpolation.py index 6e1d73e8..4f96d804 100644 --- a/tests/test_utils_interpolation.py +++ b/tests/test_utils_interpolation.py @@ -1,4 +1,4 @@ -"""Unit tests for ``lfkit.utils.interpolation`.py``.""" +"""Unit tests for ``lfkit.utils.interpolation``.""" from __future__ import annotations @@ -6,9 +6,10 @@ import pytest from lfkit.utils.interpolation import ( + as_1d_finite_grid, + build_1d_interpolator, linear_interp_extrap, prep_strictly_increasing_xy, - build_1d_interpolator, ) @@ -129,3 +130,231 @@ def test_build_1d_interpolator_raises_on_unknown_extrap_mode(): y = np.array([0.0, 1.0]) with pytest.raises(ValueError): build_1d_interpolator(z, y, method="linear", extrapolate=True, extrap_mode="nope") # type: ignore[arg-type] + + +def test_interpolation_exports_expected_public_names() -> None: + """Tests that interpolation exposes the expected public API names.""" + import lfkit.utils.interpolation as interpolation + + expected = { + "linear_interp_extrap", + "build_1d_interpolator", + "prep_strictly_increasing_xy", + "as_1d_finite_grid", + } + + assert set(interpolation.__all__) == expected + + +def test_linear_interp_extrap_supports_scalar_query() -> None: + """Tests that linear_interp_extrap supports scalar query input.""" + result = linear_interp_extrap( + np.asarray(0.5), + np.array([0.0, 1.0], dtype=float), + np.array([0.0, 2.0], dtype=float), + ) + + assert result.shape == () + assert result == pytest.approx(1.0) + + +def test_linear_interp_extrap_returns_numpy_interp_for_single_sample() -> None: + """Tests that linear_interp_extrap falls back to numpy.interp for one sample.""" + result = linear_interp_extrap( + np.array([-1.0, 0.0, 1.0], dtype=float), + np.array([0.0], dtype=float), + np.array([2.0], dtype=float), + ) + + np.testing.assert_allclose(result, np.array([2.0, 2.0, 2.0])) + + +def test_linear_interp_extrap_returns_float64_array() -> None: + """Tests that linear_interp_extrap returns a float64 array.""" + result = linear_interp_extrap( + np.array([0, 1, 2]), + np.array([0, 2]), + np.array([0, 4]), + ) + + assert isinstance(result, np.ndarray) + assert result.dtype == np.float64 + + +def test_prep_strictly_increasing_xy_converts_integer_inputs() -> None: + """Tests that interpolation preparation converts integer inputs to floats.""" + z_out, y_out = prep_strictly_increasing_xy( + np.array([2, 0, 1]), + np.array([4, 0, 1]), + ) + + assert z_out.dtype == np.float64 + assert y_out.dtype == np.float64 + np.testing.assert_allclose(z_out, np.array([0.0, 1.0, 2.0])) + np.testing.assert_allclose(y_out, np.array([0.0, 1.0, 4.0])) + + +def test_prep_strictly_increasing_xy_keeps_first_duplicate_after_sort() -> None: + """Tests that interpolation preparation keeps the first sorted duplicate.""" + z_out, y_out = prep_strictly_increasing_xy( + np.array([0.0, 1.0, 1.0, 2.0]), + np.array([0.0, 10.0, 99.0, 20.0]), + ) + + np.testing.assert_allclose(z_out, np.array([0.0, 1.0, 2.0])) + np.testing.assert_allclose(y_out, np.array([0.0, 10.0, 20.0])) + + +def test_build_1d_interpolator_linear_no_extrap_clamps_outside() -> None: + """Tests that linear interpolation without extrapolation clamps outside values.""" + z = np.array([0.0, 1.0, 2.0], dtype=float) + y = np.array([10.0, 20.0, 30.0], dtype=float) + + f = build_1d_interpolator( + z, + y, + method="linear", + extrapolate=False, + ) + + result = f(np.array([-1.0, 0.5, 3.0], dtype=float)) + + np.testing.assert_allclose(result, np.array([10.0, 15.0, 30.0])) + + +def test_build_1d_interpolator_linear_none_mode_clamps_outside() -> None: + """Tests that linear interpolation with none extrapolation mode clamps outside.""" + z = np.array([0.0, 1.0, 2.0], dtype=float) + y = np.array([10.0, 20.0, 30.0], dtype=float) + + f = build_1d_interpolator( + z, + y, + method="linear", + extrapolate=True, + extrap_mode="none", + ) + + result = f(np.array([-1.0, 0.5, 3.0], dtype=float)) + + np.testing.assert_allclose(result, np.array([10.0, 15.0, 30.0])) + + +def test_build_1d_interpolator_linear_native_extrap_matches_linear_extrap() -> None: + """Tests that linear native extrapolation matches linear_interp_extrap.""" + z = np.array([0.0, 1.0, 3.0], dtype=float) + y = np.array([10.0, 12.0, 20.0], dtype=float) + x = np.array([-1.0, 0.5, 4.0], dtype=float) + + f = build_1d_interpolator( + z, + y, + method="linear", + extrapolate=True, + extrap_mode="native", + ) + + np.testing.assert_allclose(f(x), linear_interp_extrap(x, z, y)) + + +def test_build_1d_interpolator_sorts_unsorted_inputs() -> None: + """Tests that interpolator construction sorts unsorted tabulated inputs.""" + z = np.array([2.0, 0.0, 1.0], dtype=float) + y = np.array([20.0, 0.0, 10.0], dtype=float) + + f = build_1d_interpolator( + z, + y, + method="linear", + extrapolate=False, + ) + + result = f(np.array([0.5, 1.5], dtype=float)) + + np.testing.assert_allclose(result, np.array([5.0, 15.0])) + + +def test_build_1d_interpolator_filters_nonfinite_inputs() -> None: + """Tests that interpolator construction filters non-finite tabulated inputs.""" + z = np.array([0.0, 1.0, np.nan, 2.0], dtype=float) + y = np.array([0.0, 10.0, 99.0, 20.0], dtype=float) + + f = build_1d_interpolator( + z, + y, + method="linear", + extrapolate=False, + ) + + result = f(np.array([0.5, 1.5], dtype=float)) + + np.testing.assert_allclose(result, np.array([5.0, 15.0])) + + +def test_build_1d_interpolator_rejects_too_few_valid_points() -> None: + """Tests that interpolator construction rejects too few valid points.""" + with pytest.raises(ValueError, match="Need at least 2 points"): + build_1d_interpolator( + np.array([0.0, np.nan], dtype=float), + np.array([1.0, 2.0], dtype=float), + method="linear", + extrapolate=False, + ) + + +def test_build_1d_interpolator_linear_tail_supports_scalar_query() -> None: + """Tests that linear-tail extrapolation supports scalar query input.""" + z = np.array([0.0, 1.0, 2.0], dtype=float) + y = np.array([10.0, 20.0, 30.0], dtype=float) + + f = build_1d_interpolator( + z, + y, + method="linear", + extrapolate=True, + extrap_mode="linear_tail", + ) + + result = f(np.asarray(3.0)) + + assert result.shape == () + assert result == pytest.approx(40.0) + + +def test_as_1d_finite_grid_accepts_valid_grid() -> None: + """Tests that as_1d_finite_grid accepts a finite one-dimensional grid.""" + result = as_1d_finite_grid([0.0, 0.5, 1.0], name="z") + + assert isinstance(result, np.ndarray) + assert result.dtype == np.float64 + np.testing.assert_allclose(result, np.array([0.0, 0.5, 1.0])) + + +def test_as_1d_finite_grid_rejects_scalar_input() -> None: + """Tests that as_1d_finite_grid rejects scalar input.""" + with pytest.raises(ValueError, match="z must be a finite 1D array"): + as_1d_finite_grid(0.5, name="z") + + +def test_as_1d_finite_grid_rejects_single_point() -> None: + """Tests that as_1d_finite_grid rejects one-point grids.""" + with pytest.raises(ValueError, match="z must be a finite 1D array"): + as_1d_finite_grid([0.5], name="z") + + +def test_as_1d_finite_grid_rejects_multidimensional_input() -> None: + """Tests that as_1d_finite_grid rejects multidimensional input.""" + with pytest.raises(ValueError, match="z must be a finite 1D array"): + as_1d_finite_grid([[0.0, 0.5], [1.0, 1.5]], name="z") + + +def test_as_1d_finite_grid_rejects_nan_values() -> None: + """Tests that as_1d_finite_grid rejects NaN values.""" + with pytest.raises(ValueError, match="z must be a finite 1D array"): + as_1d_finite_grid([0.0, np.nan, 1.0], name="z") + + +def test_as_1d_finite_grid_rejects_infinite_values() -> None: + """Tests that as_1d_finite_grid rejects infinite values.""" + with pytest.raises(ValueError, match="z must be a finite 1D array"): + as_1d_finite_grid([0.0, np.inf, 1.0], name="z") diff --git a/tests/test_utils_io.py b/tests/test_utils_io.py index d51ee3e7..947b422b 100644 --- a/tests/test_utils_io.py +++ b/tests/test_utils_io.py @@ -6,12 +6,14 @@ import pytest from lfkit.utils.io import ( - load_vizier_csv, + POGGIANTI1997_PKG, available_from_table, - extract_series, available_pairs, - save_kcorr_package, + extract_series, load_kcorr_package, + load_vizier_csv, + resolve_packaged_csv, + save_kcorr_package, ) @@ -126,3 +128,220 @@ def test_save_and_load_kcorr_package_roundtrip(tmp_path): for t in pkg["types"]: assert np.allclose(loaded["K"][t], pkg["K"][t]) assert loaded["meta"]["tag"] == "x" + + +def test_io_exports_expected_public_names() -> None: + """Tests that io exposes the expected public API names.""" + import lfkit.utils.io as io + + expected = { + "POGGIANTI1997_PKG", + "load_vizier_csv", + "resolve_packaged_csv", + "available_from_table", + "extract_series", + "available_pairs", + "save_kcorr_package", + "load_kcorr_package", + } + + assert set(io.__all__) == expected + + +def test_poggianti1997_package_name_is_expected() -> None: + """Tests that the Poggianti package-data namespace is stable.""" + assert POGGIANTI1997_PKG == "lfkit.data.poggianti1997" + + +def test_load_vizier_csv_accepts_string_path(tmp_path) -> None: + """Tests that load_vizier_csv accepts string paths.""" + csv = tmp_path / "tab.csv" + csv.write_text("recno,z,Filt,E\n1,0.0,b1,0.1\n") + + tab = load_vizier_csv(str(csv)) + + assert tab.dtype.names is not None + assert set(tab.dtype.names) >= {"recno", "z", "Filt", "E"} + + +def test_resolve_packaged_csv_returns_existing_package_path() -> None: + """Tests that resolve_packaged_csv returns a path for packaged resources.""" + path = resolve_packaged_csv("__init__.py", pkg="lfkit") + + assert path.name == "__init__.py" + assert path.exists() + + +def test_available_from_table_strips_blank_band_labels() -> None: + """Tests that available_from_table strips band labels and drops blanks.""" + dtype = [("z", "f8"), ("Filt", "U20"), ("E", "f8")] + tab = np.array( + [ + (0.0, " b2 ", 0.1), + (0.1, "b1", 0.2), + (0.2, " ", 0.3), + ], + dtype=dtype, + ) + + bands, seds = available_from_table(tab) + + assert bands == ["b1", "b2"] + assert seds == ["E"] + + +def test_available_from_table_excludes_recno_z_and_filt_only() -> None: + """Tests that available_from_table excludes only metadata columns from SEDs.""" + dtype = [ + ("recno", "i4"), + ("z", "f8"), + ("Filt", "U20"), + ("E", "f8"), + ("Sa", "f8"), + ] + tab = np.array([(1, 0.0, "b1", 0.1, 0.2)], dtype=dtype) + + _, seds = available_from_table(tab) + + assert seds == ["E", "Sa"] + + +def test_extract_series_returns_float64_arrays() -> None: + """Tests that extract_series returns float64 arrays.""" + tab = _dummy_poggianti_table() + + z, y = extract_series(tab, band="b2", sed="Sc", min_points=2) + + assert z.dtype == np.float64 + assert y.dtype == np.float64 + + +def test_extract_series_strips_table_band_values() -> None: + """Tests that extract_series matches bands after stripping table values.""" + dtype = [("z", "f8"), ("Filt", "U20"), ("E", "f8")] + tab = np.array( + [ + (0.0, " b1 ", 0.0), + (0.1, " b1 ", 0.1), + (0.2, " b1 ", 0.2), + ], + dtype=dtype, + ) + + z, y = extract_series(tab, band="b1", sed="E", min_points=3) + + np.testing.assert_allclose(z, np.array([0.0, 0.1, 0.2])) + np.testing.assert_allclose(y, np.array([0.0, 0.1, 0.2])) + + +def test_extract_series_rejects_all_nonfinite_values() -> None: + """Tests that extract_series rejects selections with no finite values.""" + dtype = [("z", "f8"), ("Filt", "U20"), ("E", "f8")] + tab = np.array( + [ + (0.0, "b1", np.nan), + (0.1, "b1", np.inf), + ], + dtype=dtype, + ) + + with pytest.raises(ValueError, match="No finite values"): + extract_series(tab, band="b1", sed="E", min_points=1) + + +def test_extract_series_keeps_first_duplicate_after_sorting() -> None: + """Tests that extract_series keeps the first duplicate after sorting.""" + dtype = [("z", "f8"), ("Filt", "U20"), ("E", "f8")] + tab = np.array( + [ + (0.0, "b1", 0.0), + (0.1, "b1", 1.0), + (0.1, "b1", 9.0), + (0.2, "b1", 2.0), + ], + dtype=dtype, + ) + + z, y = extract_series(tab, band="b1", sed="E", min_points=3) + + np.testing.assert_allclose(z, np.array([0.0, 0.1, 0.2])) + np.testing.assert_allclose(y, np.array([0.0, 1.0, 2.0])) + + +def test_available_pairs_returns_empty_lists_for_unusable_bands() -> None: + """Tests that available_pairs keeps bands even when no SEDs are usable.""" + dtype = [("z", "f8"), ("Filt", "U20"), ("E", "f8")] + tab = np.array( + [ + (0.0, "b1", 0.0), + (0.1, "b1", 0.1), + (0.0, "b2", np.nan), + (0.1, "b2", np.nan), + ], + dtype=dtype, + ) + + pairs = available_pairs(tab, min_points=2) + + assert pairs == {"b1": ["E"], "b2": []} + + +def test_save_kcorr_package_creates_parent_directories(tmp_path) -> None: + """Tests that save_kcorr_package creates missing parent directories.""" + pkg = dict( + meta={"tag": "x"}, + z=np.array([0.0, 0.1]), + responses_in=["r"], + responses_out=["r"], + responses_map=["r"], + types=["E"], + K={"E": np.array([[0.0], [0.1]])}, + ) + out = tmp_path / "nested" / "pkg.npz" + + save_kcorr_package(pkg, out) + + assert out.exists() + + +def test_save_and_load_kcorr_package_accepts_string_path(tmp_path) -> None: + """Tests that k-correction package I/O accepts string paths.""" + pkg = dict( + meta={"tag": "x"}, + z=np.array([0.0, 0.1]), + responses_in=["r"], + responses_out=["r"], + responses_map=["r"], + types=["E"], + K={"E": np.array([[0.0], [0.1]])}, + ) + out = tmp_path / "pkg.npz" + + save_kcorr_package(pkg, str(out)) + loaded = load_kcorr_package(str(out)) + + np.testing.assert_allclose(loaded["z"], pkg["z"]) + np.testing.assert_allclose(loaded["K"]["E"], pkg["K"]["E"]) + + +def test_load_kcorr_package_removes_internal_metadata_from_meta(tmp_path) -> None: + """Tests that load_kcorr_package removes internal metadata keys from meta.""" + pkg = dict( + meta={"tag": "x", "version": 1}, + z=np.array([0.0, 0.1]), + responses_in=["r_in"], + responses_out=["r_out"], + responses_map=["r_map"], + types=["E"], + K={"E": np.array([[0.0], [0.1]])}, + ) + out = tmp_path / "pkg.npz" + + save_kcorr_package(pkg, out) + loaded = load_kcorr_package(out) + + assert loaded["meta"] == {"tag": "x", "version": 1} + assert "responses_in" not in loaded["meta"] + assert "responses_out" not in loaded["meta"] + assert "responses_map" not in loaded["meta"] + assert "types" not in loaded["meta"] diff --git a/tests/test_utils_types.py b/tests/test_utils_types.py new file mode 100644 index 00000000..251edc61 --- /dev/null +++ b/tests/test_utils_types.py @@ -0,0 +1,75 @@ +"""Unit tests for ``lfkit.utils.types``.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any, get_args, get_origin + +import numpy as np + +import lfkit.utils.types as lf_types + + +def test_types_exports_expected_public_names() -> None: + """Tests that types exposes the expected public API names.""" + expected = { + "Cosmology", + "FloatArray", + "FloatInput", + "LuminosityFunction", + "ParameterModel", + "ParameterValue", + "ConditionalParameter", + } + + assert set(lf_types.__all__) == expected + + +def test_float_array_alias_points_to_numpy_ndarray() -> None: + """Tests that FloatArray aliases a NumPy ndarray type.""" + assert get_origin(lf_types.FloatArray) is np.ndarray + assert "float64" in str(lf_types.FloatArray) + + +def test_float_input_includes_expected_user_input_forms() -> None: + """Tests that FloatInput includes scalar, sequence, and array input forms.""" + args = get_args(lf_types.FloatInput) + + assert float in args + assert Sequence[float] in args + assert lf_types.FloatArray in args + + +def test_parameter_value_matches_float_input() -> None: + """Tests that ParameterValue is the FloatInput alias.""" + assert lf_types.ParameterValue == lf_types.FloatInput + + +def test_parameter_model_is_callable_alias() -> None: + """Tests that ParameterModel is a callable alias.""" + assert get_origin(lf_types.ParameterModel) is Callable + assert lf_types.FloatArray in get_args(lf_types.ParameterModel) + + +def test_cosmology_alias_is_any() -> None: + """Tests that Cosmology is the Any alias.""" + assert lf_types.Cosmology is Any + + +def test_luminosity_function_is_callable_alias() -> None: + """Tests that LuminosityFunction is a callable alias.""" + assert get_origin(lf_types.LuminosityFunction) is Callable + assert lf_types.FloatArray in get_args(lf_types.LuminosityFunction) + + +def test_conditional_parameter_includes_parameter_value_text() -> None: + """Tests that ConditionalParameter includes the parameter-value alias.""" + assert str(lf_types.ParameterValue) in str(lf_types.ConditionalParameter) + + +def test_conditional_parameter_includes_callable_text() -> None: + """Tests that ConditionalParameter includes callable parameter models.""" + assert "Callable" in str(lf_types.ConditionalParameter) + assert "FloatArray" not in str(lf_types.ConditionalParameter) or "ndarray" in str( + lf_types.ConditionalParameter + ) diff --git a/tests/test_utils_units.py b/tests/test_utils_units.py index 91579a91..e83e8ad6 100644 --- a/tests/test_utils_units.py +++ b/tests/test_utils_units.py @@ -1,4 +1,4 @@ -"""Unit tests for the ``lfkit.utils.units.py``""" +"""Unit tests for the ``lfkit.utils.units``.""" from __future__ import annotations @@ -88,3 +88,112 @@ def test_magerr_to_ivar_maggies_matches_propagation_and_masks_bad(): assert np.allclose(ivar, expected, rtol=0.0, atol=0.0) assert ivar[1] == 0.0 # nan sigma_m -> 0 ivar assert ivar[2] == 0.0 # zero sigma_m -> 0 ivar + + +def test_h0_conversion_accepts_scalar_like_inputs() -> None: + """Tests that H0 conversion accepts NumPy scalar inputs.""" + h0 = np.float64(100.0) + got = h0_km_s_mpc_to_gyr_inv(h0) + expected = (100.0 / km_per_mpc()) * sec_per_gyr() + assert np.isclose(got, expected, rtol=0.0, atol=0.0) + + +def test_h0_conversion_preserves_sign() -> None: + """Tests that H0 conversion is algebraic and preserves sign.""" + assert h0_km_s_mpc_to_gyr_inv(0.0) == 0.0 + assert h0_km_s_mpc_to_gyr_inv(-70.0) < 0.0 + + +def test_mag_to_maggies_accepts_scalar_input() -> None: + """Tests that scalar magnitude input returns scalar-shaped maggies.""" + got = mag_to_maggies(0.0) + assert np.shape(got) == () + assert np.isclose(got, 1.0, rtol=0.0, atol=0.0) + + +def test_maggies_to_mag_accepts_scalar_input() -> None: + """Tests that scalar maggie input returns scalar-shaped magnitude.""" + got = maggies_to_mag(1.0) + assert np.shape(got) == () + assert np.isclose(got, 0.0, rtol=0.0, atol=0.0) + + +def test_mag_to_maggies_preserves_array_shape() -> None: + """Tests that magnitude-to-maggies conversion preserves input shape.""" + m = np.array([[0.0, 2.5], [5.0, 7.5]]) + f = mag_to_maggies(m) + + assert f.shape == m.shape + np.testing.assert_allclose(f, 10.0 ** (-0.4 * m)) + + +def test_maggies_to_mag_preserves_array_shape() -> None: + """Tests that maggies-to-magnitude conversion preserves input shape.""" + f = np.array([[1.0, 0.1], [0.01, 0.001]]) + m = maggies_to_mag(f) + + assert m.shape == f.shape + np.testing.assert_allclose(m, -2.5 * np.log10(f)) + + +def test_magnitude_difference_maps_to_flux_ratio() -> None: + """Tests that a 2.5 mag increase lowers maggies by a factor of 10.""" + f0 = mag_to_maggies(20.0) + f1 = mag_to_maggies(22.5) + + np.testing.assert_allclose(f1 / f0, 0.1, rtol=1e-14) + + +def test_maggies_to_mag_custom_floor() -> None: + """Tests that a custom floor controls the maximum returned magnitude.""" + m = maggies_to_mag(np.array([0.0, 1e-20]), floor=1e-10) + + np.testing.assert_allclose(m, np.array([25.0, 25.0])) + + +def test_magerr_to_ivar_maggies_broadcasts_inputs() -> None: + """Tests that magerr_to_ivar_maggies supports NumPy broadcasting.""" + m = np.array([20.0, 21.0, 22.0]) + sigma_m = 0.1 + + ivar = magerr_to_ivar_maggies(m, sigma_m) + + f = mag_to_maggies(m) + sigma_f = (0.4 * np.log(10.0)) * f * sigma_m + expected = 1.0 / sigma_f**2 + + assert ivar.shape == m.shape + np.testing.assert_allclose(ivar, expected) + + +def test_magerr_to_ivar_maggies_negative_sigma_is_masked() -> None: + """Tests that negative magnitude uncertainties produce zero inverse variance.""" + ivar = magerr_to_ivar_maggies( + np.array([20.0, 21.0]), + np.array([-0.1, 0.1]), + ) + + assert ivar[0] == 0.0 + assert ivar[1] > 0.0 + + +def test_magerr_to_ivar_maggies_infinite_sigma_is_masked() -> None: + """Tests that infinite propagated uncertainty produces zero inverse variance.""" + ivar = magerr_to_ivar_maggies( + np.array([20.0, 21.0]), + np.array([np.inf, 0.1]), + ) + + assert ivar[0] == 0.0 + assert ivar[1] > 0.0 + + +def test_magerr_to_ivar_maggies_preserves_shape() -> None: + """Tests that inverse-variance conversion preserves broadcasted array shape.""" + m = np.array([[20.0, 21.0], [22.0, 23.0]]) + sigma_m = np.full_like(m, 0.1) + + ivar = magerr_to_ivar_maggies(m, sigma_m) + + assert ivar.shape == m.shape + assert np.all(ivar > 0.0) diff --git a/tests/test_utils_validators.py b/tests/test_utils_validators.py index f0cf8698..674bceec 100644 --- a/tests/test_utils_validators.py +++ b/tests/test_utils_validators.py @@ -1,4 +1,4 @@ -"""Unit tests for the ``lfkit.utils.validators.py``""" +"""Unit tests for the ``lfkit.utils.validators``.""" import numpy as np import pytest @@ -182,3 +182,90 @@ def test_validate_luminosity_distance_rejects_negative_infinity() -> None: match="luminosity_distance_mpc contains NaN or infinite values", ): validate_luminosity_distance([10.0, -np.inf]) + + +def test_validate_array_preserves_multidimensional_shape() -> None: + """Tests that validate_array preserves multidimensional input shape.""" + x = np.array([[1.0, 2.0], [3.0, 4.0]]) + + result = validate_array(x, name="x") + + assert result.shape == x.shape + np.testing.assert_allclose(result, x) + + +def test_validate_array_accepts_tuple_input() -> None: + """Tests that validate_array accepts tuple input.""" + result = validate_array((1.0, 2.0, 3.0), name="x") + + np.testing.assert_allclose(result, np.array([1.0, 2.0, 3.0])) + assert result.dtype == np.float64 + + +def test_validate_array_casts_float32_to_float64() -> None: + """Tests that validate_array casts float32 arrays to float64.""" + x = np.array([1.0, 2.0, 3.0], dtype=np.float32) + + result = validate_array(x, name="x") + + assert result.dtype == np.float64 + np.testing.assert_allclose(result, x) + + +def test_validate_array_accepts_empty_array() -> None: + """Tests that validate_array accepts empty finite arrays.""" + result = validate_array([], name="x") + + assert result.dtype == np.float64 + assert result.shape == (0,) + + +def test_validate_array_rejects_negative_scalar_when_disallowed() -> None: + """Tests that a negative scalar is rejected when negatives are disallowed.""" + with pytest.raises(ValueError, match="x contains negative values"): + validate_array(-1.0, name="x", allow_negative=False) + + +def test_validate_luminosity_distance_preserves_multidimensional_shape() -> None: + """Tests that luminosity-distance validation preserves multidimensional shape.""" + distance = np.array([[10.0, 100.0], [1000.0, 2000.0]]) + + result = validate_luminosity_distance(distance) + + assert result.shape == distance.shape + np.testing.assert_allclose(result, distance) + + +def test_validate_luminosity_distance_rejects_zero_scalar() -> None: + """Tests that scalar zero luminosity distance is rejected.""" + with pytest.raises( + ValueError, + match="luminosity_distance_mpc must contain positive values", + ): + validate_luminosity_distance(0.0) + + +def test_validate_luminosity_distance_rejects_negative_scalar() -> None: + """Tests that scalar negative luminosity distance is rejected.""" + with pytest.raises( + ValueError, + match="luminosity_distance_mpc contains negative values", + ): + validate_luminosity_distance(-1.0) + + +def test_validate_magnitude_range_accepts_zero_width_sign_crossing_range() -> None: + """Tests that magnitude ranges may cross zero if faint is larger than bright.""" + validate_magnitude_range(m_bright=-1.0, m_faint=1.0) + + +def test_validate_magnitude_range_rejects_negative_infinite_bright_bound() -> None: + """Tests that negative infinite bright bounds are rejected.""" + with pytest.raises(ValueError, match="m_bright must be finite"): + validate_magnitude_range(m_bright=-np.inf, m_faint=-18.0) + + +def test_validate_magnitude_range_rejects_negative_infinite_faint_bound() -> None: + """Tests that negative infinite faint bounds are rejected.""" + with pytest.raises(ValueError, match="m_faint must be finite"): + validate_magnitude_range(m_bright=-24.0, m_faint=-np.inf)