diff --git a/coolest/api/composable_models.py b/coolest/api/composable_models.py index e154063..45d28ab 100644 --- a/coolest/api/composable_models.py +++ b/coolest/api/composable_models.py @@ -145,6 +145,10 @@ def _get_grid_params(profile_in, fits_dir): fov_y = profile_in.parameters['pixels'].field_of_view_y npix = profile_in.parameters['pixels'].num_pix fixed_parameters = (fov_x, fov_y, npix) + + else: + raise ValueError(f"Unsupported grid profile type '{profile_in}'.") + return parameters, fixed_parameters @staticmethod diff --git a/coolest/api/util.py b/coolest/api/util.py index fbd7851..10c8625 100644 --- a/coolest/api/util.py +++ b/coolest/api/util.py @@ -6,8 +6,6 @@ # from astropy.coordinates import SkyCoord from skimage import measure -from coolest.template.json import JSONSerializer - def convert_image_to_data_units(image, pixel_size, mag_tot, mag_zero_point): """ @@ -27,11 +25,12 @@ def convert_image_to_data_units(image, pixel_size, mag_tot, mag_zero_point): return image_unit_flux * flux_unit_mag -def get_coolest_object(file_path, verbose=False, **kwargs_serializer): +def get_coolest_object(file_path, verbose=False, kwargs_validator={}, **kwargs_serializer): + from coolest.template.json import JSONSerializer # prevents circular imports if not os.path.isabs(file_path): file_path = os.path.abspath(file_path) serializer = JSONSerializer(file_path, **kwargs_serializer) - return serializer.load(verbose=verbose) + return serializer.load(verbose=verbose, **kwargs_validator) def get_coordinates(coolest_object, offset_x=0., offset_y=0.): diff --git a/coolest/template/classes/parameter.py b/coolest/template/classes/parameter.py index d4df00c..82aa1a2 100644 --- a/coolest/template/classes/parameter.py +++ b/coolest/template/classes/parameter.py @@ -138,6 +138,26 @@ def set_point_estimate(self, point_estimate): if max_val is not None and np.any(np.asarray(val) > np.asarray(max_val)): raise ValueError(f"Value cannot be larger than {self.definition_range.max_value}.") + def set_flag(self, flag): + """Set the parameter as a flag. + + Parameters + ---------- + flag : bool, string + Flag value + + Raises + ------ + ValueError + If the provided flag has not a supported type. + """ + if isinstance(flag, (bool, str)): + self.point_estimate = flag + else: + raise ValueError("Flag value must be either a boolean or a string (bool or str).") + + + def remove_point_estimate(self): """Remove the current point estimate of the parameter. """ @@ -272,4 +292,4 @@ class IrregularGridParameter(IrregularGrid): def __init__(self, documentation, **kwargs_grid) -> None: self.documentation = documentation super().__init__(**kwargs_grid) - \ No newline at end of file + diff --git a/coolest/template/classes/profiles/light.py b/coolest/template/classes/profiles/light.py index 0b905f2..600c5e6 100644 --- a/coolest/template/classes/profiles/light.py +++ b/coolest/template/classes/profiles/light.py @@ -15,7 +15,7 @@ 'Chameleon', 'Uniform', 'Shapelets', - 'LensedPS', + 'PointSource', 'PixelatedRegularGrid', 'IrregularGrid', ] @@ -131,31 +131,58 @@ def __init__(self): super().__init__(parameters) -class LensedPS(AnalyticalProfile): - """Surface brightness of a set of point sources after being lensed. +class PointSource(AnalyticalProfile): + """Surface brightness of a point source before and/or after being lensed. This profile is described by the following parameters: - - - 'ra_list': list of coordinates along the x axis - - 'dec_list': list of coordinates along the y axis - - 'amps': list of amplitudes + - 'x_intrinsic': the value of the intrinsic, unlensed x-axis position of the source + - 'y_intrinsic': the value of the intrinsic, unlensed y-axis position of the source + - 'f_intrinsic': the value of the intrinsic, unlensed flux (in data units) of the source + - 'x_lensed': list of coordinates along the x axis of the multiple images + - 'y_lensed': list of coordinates along the y axis of the multiple images + - 'f_lensed': list of fluxes (in data units) of the multiple images + + It also has a property to dynamically check what the point source profile is describing: + - 'flag_contains' ('intrinsic','lensed','both', None): + whether the profile contains only the lensed properties, only the intrinsic ones, both, or nothing. """ def __init__(self): - documentation = "Set of lensed point sources" + documentation = "Set of point source and lensed multiple images" parameters = { - 'ra_list': NonLinearParameterSet("RA positions of the lensed point sources", - DefinitionRange(), - latex_str=r"$ra$"), - 'dec_list': NonLinearParameterSet("DEC positions of the lensed point sources", - DefinitionRange(), - latex_str=r"$dec$"), - 'amps': LinearParameterSet("Set of amplitude values for the lensed point sources", - DefinitionRange(min_value=0.0), - latex_str=r"$A$"), + 'x_intrinsic': NonLinearParameter("X-axis position of the intrinsic, unlensed point source", + DefinitionRange(), + latex_str=r"$ra$"), + 'y_intrinsic': NonLinearParameter("Y-axis position of the intrinsic, unlensed point source", + DefinitionRange(), + latex_str=r"$dec$"), + 'f_intrinsic': LinearParameter("Flux (in data units) of the intrinsic, unlensed point source", + DefinitionRange(min_value=0.0), + latex_str=r"$A$"), + 'x_lensed': NonLinearParameterSet("X-axis positions of the multiple images", + DefinitionRange(), + latex_str=r"$ra$"), + 'y_lensed': NonLinearParameterSet("Y-axis positions of the multiple images", + DefinitionRange(), + latex_str=r"$dec$"), + 'f_lensed': LinearParameterSet("Set of flux values (in data units) of the multiple images", + DefinitionRange(min_value=0.0), + latex_str=r"$A$"), } super().__init__(parameters) + @property + def flag_contains(self): + flag_int = self.parameters['f_intrinsic'].point_estimate.value is not None + flag_len = self.parameters['f_lensed'].point_estimate.value is not None + if flag_int and flag_len: + return 'both' + elif flag_int: + return 'intrinsic' + elif flag_len: + return 'lensed' + return None + class Uniform(AnalyticalProfile): """Uniform surface brightness profile. diff --git a/coolest/template/json.py b/coolest/template/json.py index 81e7704..bdaefa4 100644 --- a/coolest/template/json.py +++ b/coolest/template/json.py @@ -7,6 +7,7 @@ from coolest.template.lazy import * from coolest.template.classes.parameter import PointEstimate, PosteriorStatistics, Prior from coolest.template.info import all_supported_choices as support +from coolest.template.validation import Validator __all__ = ['JSONSerializer'] @@ -88,7 +89,7 @@ def dump_jsonpickle(self): with open(json_path, 'w') as f: f.write(result) - def load(self, skip_jsonpickle=False, verbose=True): + def load(self, skip_jsonpickle=False, verbose=True, **kwargs_validator): """Read the JSON template file and build up the corresponding COOLEST object. It will first try to load the '_pyAPI' template if it exists using `jsonpickle`, otherwise it will fall back to reading the pure json template. @@ -99,6 +100,9 @@ def load(self, skip_jsonpickle=False, verbose=True): If True, will not try to read the _pyAPI template with jsonpickle first, by default False verbose : bool, optional If True, prints useful output for debugging, by default False + kwargs_validator : dict, optional + Keyword arguments for the validate() method of the Validator + that checks self-consistency of the loaded COOLEST instance. Returns ------- @@ -114,6 +118,9 @@ def load(self, skip_jsonpickle=False, verbose=True): print(f"Template file '{jsonpickle_path}' not found, now trying to read '{json_path}'.") instance = self.load_simple(json_path, as_object=True) assert isinstance(instance, COOLEST) + # check consistency across the whole coolest object + validator = Validator(instance, self._json_dir) + validator.validate(**kwargs_validator) return instance def load_simple(self, json_path, as_object=True): @@ -200,40 +207,8 @@ def _json_to_coolest(self, json_content): instrument, cosmology=cosmology, metadata=metadata) - - # check consistency across the whole coolest object - self._validate_global(coolest) return coolest - @staticmethod - def _validate_global(coolest): - """Performs consistency checks regarding some key properties of the COOLEST object. - For instance, it checks that the pixel size of both the observation and - the instrument are consistent. - The checks performed here are those that cannot be handled by individual - class constructors called during instantiation of the COOLEST object. - - Parameters - ---------- - coolest : COOLEST object - Instance of a COOLEST object - - Raises - ------ - ValueError - In case observed instrumental pixel sizes are inconsistent - """ - # PIXEL SIZE - instru_pix_size = coolest.instrument.pixel_size - obs_pix_size = coolest.observation.pixels.pixel_size - isclose_bool = math.isclose(instru_pix_size, obs_pix_size, - rel_tol=1e-09, abs_tol=0.0) - if obs_pix_size not in (0, None) and not isclose_bool: - raise ValueError(f"Pixel size of observation ({obs_pix_size}) is inconsistent with " - f"the instrument pixel size ({instru_pix_size})") - - # TODO: add extra checks - def _setup_instrument(self, instru_in): psf_settings = instru_in.pop('psf') psf = self._setup_psf(psf_settings) diff --git a/coolest/template/validation.py b/coolest/template/validation.py new file mode 100644 index 0000000..ae877b5 --- /dev/null +++ b/coolest/template/validation.py @@ -0,0 +1,119 @@ +__author__ = 'aymgal' + +import math + +from coolest.template.classes.galaxy import Galaxy +from coolest.api.composable_models import ComposableMassModel + +# This submodule defines routines that validate the consistency of a COOLEST instance, +# *after* it has been successfully initialized using the `json` submodule. + + +class Validator(object): + """Classes that checks self-consistency of a COOLEST object. + + Parameters + ---------- + coolest : COOLEST + `COOLEST` instance + coolest_dir : _type_ + Directory containing the `COOLEST` instance + """ + + def __init__(self, coolest, coolest_dir): + self.coolest = coolest + self.dir = coolest_dir + + def validate(self, check_point_sources=True, source_plane_tolerance=1e-3): + """Performs consistency checks regarding some key properties of the COOLEST object. + For instance, it checks that the pixel size of both the observation and + the instrument are consistent. + The checks performed here are those that cannot be handled by individual + class constructors called during instantiation of the COOLEST object. + + Parameters + ---------- + check_point_sources : bool, optional + _description_, by default True + source_plane_tolerance : _type_, optional + _description_, by default 1e-3 + """ + # PIXEL SIZE + self.validate_pix_size(self.coolest) + + # LENSED vs INTRINSIC POINT SOURCE POSITIONS + if check_point_sources: + self.validate_point_sources(self.coolest, self.dir, source_plane_tolerance) + + + @staticmethod + def validate_pix_size(coolest): + """Checks that the Instrument and Observation pixel sizes are consistent. + + Raises + ------ + ValueError + If pixel sizes are inconsistent. + """ + instru_pix_size = coolest.instrument.pixel_size + obs_pix_size = coolest.observation.pixels.pixel_size + isclose_bool = math.isclose(instru_pix_size, obs_pix_size, + rel_tol=1e-09, abs_tol=0.0) + if obs_pix_size not in (0, None) and not isclose_bool: + raise ValueError(f"Pixel size of observation ({obs_pix_size}) is inconsistent with " + f"the instrument pixel size ({instru_pix_size})") + + @staticmethod + def validate_point_sources(coolest, coolest_dir, source_plane_tol): + """Checks that the PointSource light profiles, if any, are self-consistent + in terms of their point-estimate values of intrinsic and lensed positions and fluxes. + + Raises + ------ + ValueError + If point source lensed and intrinsic parameters are inconsistent. + """ + # we first decide which mass model we will use to check point source consistency + # TODO: the user may need to choose which mass model to use + # NOTE: this routine assumes single-lens plane + # here we select all entities that have mass profiles + entity_selection = [i for i, entity in enumerate(coolest.lensing_entities) if len(entity.mass_model) > 0] + mass_model = ComposableMassModel(coolest, coolest_directory=coolest_dir, + entity_selection=entity_selection, + profile_selection='all') + + # loop over all entities to find all point source light profiles + for i, entity in enumerate(coolest.lensing_entities): + if not isinstance(entity, Galaxy): + # point sources can only be in Galaxy entities + continue + for j, profile in enumerate(entity.light_model): + if profile.type != 'PointSource': + # nothing to do if not a point source + continue + elif profile.flag_contains != 'both': + # nothing to do if the point source does not contain + # both intrinsic and lensed parameters + continue + + # get lensed positions in image plane + x_img_all = profile.parameters['x_lensed'].point_estimate.value + y_img_all = profile.parameters['y_lensed'].point_estimate.value + + # compute corresponding position in source plane + x_src_all, y_src_all = mass_model.ray_shooting(x_img_all, y_img_all) + + # get intrinsic position in source plane + x_src = profile.parameters['x_intrinsic'].point_estimate.value + y_src = profile.parameters['y_intrinsic'].point_estimate.value + + # compute the differences in source plane + delta_x = x_src - x_src_all + delta_y = y_src - y_src_all + + # check that it does not exceed the tolerance + if delta_x**2 + delta_y**2 > source_plane_tol**2: + raise ValueError(f"Point source profile {j} of entity {i} " + f"with both intrinsic and lensed positions " + f"do not meet tolerance requirements in source plane.") + \ No newline at end of file diff --git a/docs/notebooks/02-generate_template.ipynb b/docs/notebooks/02-generate_template.ipynb index 826f608..6d062ba 100644 --- a/docs/notebooks/02-generate_template.ipynb +++ b/docs/notebooks/02-generate_template.ipynb @@ -11,7 +11,7 @@ "\n", "__author__: @aymgal\n", "\n", - "__last update__: 11/07/23" + "__last update__: 16/07/23" ] }, { @@ -210,16 +210,20 @@ "name": "stdout", "output_type": "stream", "text": [ - "[,\n", - " ,\n", - " ,\n", - " ,\n", - " ]\n" + "Template file '/Users/aymgal/Science/packages/my_packages/coolest/docs/notebooks/template_dir/coolest_template_pyAPI.json' not found, now trying to read '/Users/aymgal/Science/packages/my_packages/coolest/docs/notebooks/template_dir/coolest_template.json'.\n", + "[,\n", + " ,\n", + " ,\n", + " ,\n", + " ]\n" ] } ], "source": [ - "coolest_2 = serializer.load()\n", + "coolest_2 = serializer.load(\n", + " skip_jsonpickle=True, # using skip_jsonpickle=True will ensure to read the pure JSON template\n", + " check_point_sources=False, # using check_point_sources=True will check self-consistency of point source light profiles (in this example we don't have any)\n", + ")\n", "\n", "pprint(coolest_2.lensing_entities)" ] @@ -319,7 +323,7 @@ "output_type": "stream", "text": [ "All parameters with name 'q' that are not fixed:\n", - "[, , , ]\n", + "[, , , ]\n", "\n" ] } @@ -358,7 +362,7 @@ "# output to JSON\n", "template_path_mock = os.path.join(os.getcwd(), TEMPLATE_DIR, TEMPLATE_NAME+\"_mock\")\n", "serializer_mock = JSONSerializer(template_path_mock, obj=coolest_mock,\n", - " check_external_files=True)\n", + " check_external_files=True)\n", "serializer_mock.dump_simple()" ] }, @@ -387,7 +391,7 @@ "# output to JSON\n", "template_path_doc = os.path.join(os.getcwd(), TEMPLATE_DIR, TEMPLATE_NAME+\"_doc\")\n", "serializer_doc = JSONSerializer(template_path_doc, obj=coolest_doc,\n", - " check_external_files=True)\n", + " check_external_files=True)\n", "serializer_doc.dump_simple()" ] } diff --git a/docs/notebooks/template_dir/coolest_template_doc.json b/docs/notebooks/template_dir/coolest_template_doc.json index 3873de8..07f4c6d 100644 --- a/docs/notebooks/template_dir/coolest_template_doc.json +++ b/docs/notebooks/template_dir/coolest_template_doc.json @@ -531,7 +531,7 @@ "meta": {}, "mode": "DOC", "observation": { - "documentation": "Defines the observation itself, that is the image pixels, \n the exposure time, the noise model and/or properties, the magnitude \n zero-point and sky brightness.\n\n Parameters\n ----------\n pixels : PixelatedRegularGrid, optional\n Regular 2D Grid instance for the observed / mock data pixels, by default None\n noise : Noise, optional\n Instance of a Noise object associated with the modeling \n of the data pixels, by default None\n mag_zero_point : float, optional\n Zero-point magnitude, which corresponds to the 1 electron per second\n hitting the detecor (given in mag), by default None\n mag_sky_brightness : float, optional\n Magnitude due to sky brightness (given in mag per arcsec^2), \n by default None", + "documentation": "Defines the observation itself, that is the image pixels, \n the exposure time, the noise model and/or properties, the magnitude \n zero-point and sky brightness.\n\n Parameters\n ----------\n pixels : PixelatedRegularGrid, optional\n Regular 2D Grid instance for the observed / mock data pixels, by default None\n noise : Noise, optional\n Instance of a Noise object associated with the modeling \n of the data pixels, by default None\n mag_zero_point : float, optional\n Zero-point magnitude, which corresponds to the 1 electron per second\n hitting the detector (given in mag), by default None\n mag_sky_brightness : float, optional\n Magnitude due to sky brightness (given in mag per arcsec^2), \n by default None", "exposure_time": null, "mag_sky_brightness": null, "mag_zero_point": null, diff --git a/docs/notebooks/template_dir/coolest_template_pyAPI.json b/docs/notebooks/template_dir/coolest_template_pyAPI.json index ab805a6..7e60d7d 100644 --- a/docs/notebooks/template_dir/coolest_template_pyAPI.json +++ b/docs/notebooks/template_dir/coolest_template_pyAPI.json @@ -1104,7 +1104,7 @@ "with_target_shot_noise": true, "documentation": "Noise properties are computed directly based on the observed \n or modeled flux, and on the Instrument (e.g., readout noise) and \n Observation (e.g., exposure time, sky brightness, etc.) properties.\n\n Parameters\n ----------\n with_readout_noise : bool, optional\n If True, the noise includes readout noise from the detector, by default True\n with_sky_shot_noise : bool, optional\n If True, the noise includes shot noise from sky background flux \n (as the Gaussian approximation of the Poisson noise), by default True\n with_target_shot_noise : bool, optional\n If True, the noise includes shot noise from the target flux \n (as the Gaussian approximation of the Poisson noise), by default True" }, - "documentation": "Defines the observation itself, that is the image pixels, \n the exposure time, the noise model and/or properties, the magnitude \n zero-point and sky brightness.\n\n Parameters\n ----------\n pixels : PixelatedRegularGrid, optional\n Regular 2D Grid instance for the observed / mock data pixels, by default None\n noise : Noise, optional\n Instance of a Noise object associated with the modeling \n of the data pixels, by default None\n mag_zero_point : float, optional\n Zero-point magnitude, which corresponds to the 1 electron per second\n hitting the detecor (given in mag), by default None\n mag_sky_brightness : float, optional\n Magnitude due to sky brightness (given in mag per arcsec^2), \n by default None" + "documentation": "Defines the observation itself, that is the image pixels, \n the exposure time, the noise model and/or properties, the magnitude \n zero-point and sky brightness.\n\n Parameters\n ----------\n pixels : PixelatedRegularGrid, optional\n Regular 2D Grid instance for the observed / mock data pixels, by default None\n noise : Noise, optional\n Instance of a Noise object associated with the modeling \n of the data pixels, by default None\n mag_zero_point : float, optional\n Zero-point magnitude, which corresponds to the 1 electron per second\n hitting the detector (given in mag), by default None\n mag_sky_brightness : float, optional\n Magnitude due to sky brightness (given in mag per arcsec^2), \n by default None" }, "instrument": { "py/object": "coolest.template.classes.instrument.Instrument", diff --git a/docs/notebooks/template_dir/obs.fits b/docs/notebooks/template_dir/obs.fits index a467ff9..c50405e 100644 Binary files a/docs/notebooks/template_dir/obs.fits and b/docs/notebooks/template_dir/obs.fits differ