class AiryFWHMPatternData(RadialPatternData): """ A description of the properties of an airy pattern. """ amplitude: Union[float, Multivalue[float]] = extended_field( 1.0, description="amplitude") fwhm: Union[float, Multivalue[float]] = extended_field(240.0, description="FWHM [nm]") # Methods def get_numpy_array(self, canvas_inner_radius_nm: float, px_size_nm: float, extend_sides_to_diagonal: bool = False) -> np.ndarray: return generate_airy(amplitude=self.amplitude, fwhm=self.fwhm, canvas_radius=get_canvas_radius_nm( canvas_inner_radius_nm, extend_sides_to_diagonal), px_size_nm=px_size_nm) def __str__(self) -> str: return f"Airy; amplitude = {self.amplitude}, FWHM = {self.fwhm} nm"
class IlluminationResponse: """ A description of the illumination response of a fluorophore at a certain wavelength. """ wavelength: float = extended_field(observable_property( "_wavelength", default=0.0, signal_name="basic_field_changed"), description="wavelength [nm]") cross_section_off_to_on: Union[float, Multivalue[float]] = extended_field( observable_property("_cross_section_off_to_on", default=0.0, signal_name="basic_field_changed"), description="ON cross section", accept_multivalues=True) cross_section_on_to_off: Union[float, Multivalue[float]] = extended_field( observable_property("_cross_section_on_to_off", default=0.0, signal_name="basic_field_changed"), description="OFF cross section", accept_multivalues=True) cross_section_emission: Union[float, Multivalue[float]] = extended_field( observable_property("_cross_section_emission", default=0.0, signal_name="basic_field_changed"), description="emission cross section", accept_multivalues=True) # Methods def __str__(self) -> str: return f"{self.wavelength} nm"
class DoughnutPatternData(RadialPatternData): """ A description of the properties of a doughnut pattern. """ periodicity: Union[float, Multivalue[float]] = extended_field( 480.0, description="periodicity [nm]") zero_intensity: Union[float, Multivalue[float]] = extended_field( 0.0, description="relative intensity in min. [%]") # Methods def get_numpy_array(self, canvas_inner_radius_nm: float, px_size_nm: float, extend_sides_to_diagonal: bool = False) -> np.ndarray: return generate_doughnut(periodicity=self.periodicity, zero_intensity=self.zero_intensity, canvas_radius=get_canvas_radius_nm( canvas_inner_radius_nm, extend_sides_to_diagonal), px_size_nm=px_size_nm) def __str__(self) -> str: return f"Doughnut;" \ f" periodicity = {self.periodicity} nm," \ f" relative intensity in min. = {self.zero_intensity}%"
class DetectorProperties: """ A description of a detector. """ readout_noise: Union[float, Multivalue[float]] = extended_field( observable_property("_readout_noise", default=0.0, signal_name="basic_field_changed"), description="readout noise [rms]", accept_multivalues=True ) quantum_efficiency: Union[float, Multivalue[float]] = extended_field( observable_property("_quantum_efficiency", default=0.75, signal_name="basic_field_changed"), description="quantum efficiency", accept_multivalues=True ) camera_pixel_size: Optional[Union[float, Multivalue[float]]] = extended_field( # nanometres (if point detector: None) observable_property("_camera_pixel_size", default=None, signal_name="basic_field_changed"), description="camera pixel size [nm]", accept_multivalues=True ) def get_total_readout_noise_var(self, canvas_inner_radius_nm: float, pinhole_function: Pattern) -> float: """ Returns the total readout noise of the detector as a variance. """ if self.camera_pixel_size is not None: pinhole_sum = ( pinhole_function.get_numpy_array( canvas_inner_radius_nm, self.camera_pixel_size ) ** 2 ).sum() return pinhole_sum * self.readout_noise ** 2 else: return self.readout_noise ** 2
class ExplicitSampleProperties: """ An explicit description of sample-related properties in an environment. """ input_power: Union[float, Multivalue[float]] = extended_field( observable_property("_input_power", default=1.0, signal_name="basic_field_changed"), description="input power", accept_multivalues=True) D_origin: Union[float, Multivalue[float]] = extended_field( observable_property("_D_origin", default=1.0, signal_name="basic_field_changed"), description="D(0, 0)", accept_multivalues=True)
class ImagingSystemSettings: """ A description of an imaging system. """ optical_psf: Pattern = extended_field(default_factory=Pattern, description="optical PSF") pinhole_function: Pattern = extended_field(default_factory=Pattern, description="pinhole function") scanning_step_size: Union[float, Multivalue[float]] = extended_field( observable_property("_scanning_step_size", default=10.0, signal_name="basic_field_changed"), description="scanning step size [nm]", accept_multivalues=True ) refractive_index: float = observable_property( "_refractive_index", default=RefractiveIndex.oil.value, signal_name="basic_field_changed" )
class SampleProperties: """ A description of sample-related properties in an environment. """ basic_properties: ExplicitSampleProperties = extended_field( observable_property("_basic_properties", default=ExplicitSampleProperties(), signal_name="data_loaded"), description="sample") structure: Optional[SampleStructure] = extended_field( observable_property("_structure", default=None, signal_name="data_loaded"), description="structure") def load_structure(self, structure: SampleStructure) -> None: """ Loads a sample structure. """ self.basic_properties.input_power = 1.0 self.basic_properties.D_origin = 1.0 self.structure = structure def unload_structure(self) -> None: """ Unloads any currently loaded sample structure. """ self.structure = None def get_combined_properties(self, px_size_nm: float) -> ExplicitSampleProperties: """ Returns explicit sample properties. Properties derived from any loaded sample structure will take precedence over properties defined in basic_properties. """ if self.structure is not None: return self.structure.get_explicit_properties(px_size_nm) elif self.basic_properties is not None: return self.basic_properties else: raise Exception("basic_properties or structure must be set!")
class Pulse: """ A description of a laser pulse. """ wavelength: Union[float, Multivalue[float]] = extended_field( observable_property("_wavelength", default=0.0, signal_name="basic_field_changed"), description="wavelength [nm]", accept_multivalues=True ) duration: Union[float, Multivalue[float]] = extended_field( observable_property("_duration", default=0.0, signal_name="basic_field_changed"), description="duration [ms]", accept_multivalues=True ) max_intensity: Union[float, Multivalue[float]] = extended_field( observable_property("_max_intensity", default=0.0, signal_name="basic_field_changed"), description="max intensity [kW/cm²]", accept_multivalues=True ) illumination_pattern: Pattern = extended_field(default_factory=Pattern, description="illumination pattern")
class AiryNAPatternData(RadialPatternData): """ A description of the properties of an airy pattern. """ na: Union[float, Multivalue[float]] = extended_field(1.4, description="NA") emission_wavelength: Union[float, Multivalue[float]] = extended_field( 500.0, description="em. wavelength [nm]") # Methods def get_numpy_array(self, canvas_inner_radius_nm: float, px_size_nm: float, extend_sides_to_diagonal: bool = False) -> np.ndarray: return generate_airy(amplitude=1.0, fwhm=self.emission_wavelength / (2 * self.na), canvas_radius=get_canvas_radius_nm( canvas_inner_radius_nm, extend_sides_to_diagonal), px_size_nm=px_size_nm) def __str__(self) -> str: return f"Airy; NA = {self.na}, emission wavelength = {self.emission_wavelength} nm"
class PhysicalPinholePatternData(RadialPatternData): """ A description of the properties of a physical pinhole pattern. """ radius: Union[float, Multivalue[float]] = extended_field( 100.0, description="radius [nm]") # Methods def get_numpy_array(self, canvas_inner_radius_nm: float, px_size_nm: float, extend_sides_to_diagonal: bool = False) -> np.ndarray: return generate_physical_pinhole(radius=self.radius, canvas_radius=get_canvas_radius_nm( canvas_inner_radius_nm, extend_sides_to_diagonal), px_size_nm=px_size_nm) def __str__(self) -> str: return f"Physical pinhole; radius = {self.radius} nm"
class FluorophoreSettings: """ A description of a fluorophore. """ responses: List[IlluminationResponse] = extended_field( dataclass_property( fget=lambda self: self._get_responses(), fset=lambda self, responses: self._set_responses(responses), default=list), description=lambda self, index: str(self.responses[index])) # Methods def add_response(self, response: IlluminationResponse) -> bool: """ Adds a response. Returns true if successful, or false if the wavelength was invalid or there is an existing response that includes the wavelength. """ for existing_response in self._responses.values(): if existing_response.wavelength == response.wavelength: return False self._responses[response.wavelength] = response self.response_added.emit(response) return True def remove_response(self, wavelength: float) -> None: """ Removes the response with the specified wavelength attributes. """ removed_response = self._responses.pop(wavelength) self.response_removed.emit(removed_response) def clear_responses(self) -> None: """ Removes all responses. """ for wavelength in [*self._responses.keys()]: self.remove_response(wavelength) def get_response(self, wavelength: float) -> Optional[IlluminationResponse]: """ Returns the response with the specified wavelength, if it is set. If not, a response obtained from interpolation is returned if there is more than one response, the only response if there is exactly one, or None if no responses are set. """ # Look for exact match for response in self._responses.values(): if wavelength == response.wavelength: return response # Interpolate if there's more than one response if len(self._responses.values()) > 1: interp_array_x = np.zeros(len(self._responses)) interp_array_y = np.zeros((len(interp_array_x), 3)) for index, response in enumerate(self._responses.values()): interp_array_x[index] = response.wavelength interp_array_y[index] = [ response.cross_section_off_to_on, response.cross_section_on_to_off, response.cross_section_emission ] interp_function = interp1d(interp_array_x, interp_array_y, axis=0, kind=max( 1, min(len(interp_array_x) - 1, 3)), fill_value="extrapolate") return IlluminationResponse(wavelength, *interp_function(wavelength).clip(0)) # Return only response if one exists if len(self._responses.values()) == 1: return list(self._responses.values())[0] # No responses, return None return None # Internal methods def _get_responses(self) -> List[IlluminationResponse]: return [*self._responses.values()] def _set_responses(self, responses: List[IlluminationResponse]) -> None: if not isinstance(responses, list): return self.clear_responses() for response in responses: self.add_response(response)
class Pattern: """ A description of a pattern. """ pattern_type: PatternType = PatternType.array2D pattern_data: Union[dict, PatternData] = extended_field(default_factory=Array2DPatternData, description="pattern data") # Properties @property def pattern_data(self) -> PatternData: # The way we do this with allowing and converting from dicts is a bit stupid, but it seems # to be necessary in order to get dataclasses-json to encode/decode our data as wanted if isinstance(self._pattern_data, dict): self._pattern_data = self._get_data_type_from_pattern_type(self.pattern_type).from_dict( self._pattern_data ) return self._pattern_data @pattern_data.setter def pattern_data(self, pattern_data: Union[dict, PatternData]) -> None: self._pattern_data = pattern_data self.data_loaded.emit(self) # Methods def __init__(self, pattern_type: Optional[PatternType] = None, pattern_data: Optional[Union[dict, PatternData]] = None): if pattern_type is None and pattern_data is None: self.pattern_type = PatternType.array2D self.pattern_data = Array2DPatternData() elif pattern_data is not None: if isinstance(pattern_data, dict): if pattern_type is None: raise ValueError( "Pattern type must be specified when loading pattern data from dict" ) self.pattern_type = pattern_type self.pattern_data = pattern_data else: self.load_from_data(pattern_data) else: self.load_from_type(pattern_type) def load_from_type(self, pattern_type: PatternType) -> None: """ Loads the given pattern type into pattern_type and a default pattern of that type into pattern_data. """ self.pattern_type = pattern_type self.pattern_data = self._get_data_type_from_pattern_type(pattern_type)() def load_from_data(self, pattern_data: PatternData) -> None: """ Loads the given pattern data into pattern_data and the type of that data into pattern_type. """ self.pattern_type = self._get_pattern_type_from_data(pattern_data) self.pattern_data = pattern_data def get_radial_profile(self, canvas_inner_radius_nm: float, px_size_nm: float) -> np.ndarray: """ Returns a numpy array representation of the pattern data as a radial profile. """ return self.pattern_data.get_radial_profile(canvas_inner_radius_nm, px_size_nm) def get_numpy_array(self, canvas_inner_radius_nm: float, px_size_nm: float, extend_sides_to_diagonal: bool = False) -> np.ndarray: """ Returns a numpy array representation of the pattern data. """ return self.pattern_data.get_numpy_array(canvas_inner_radius_nm, px_size_nm, extend_sides_to_diagonal) def __str__(self) -> str: return str(self.pattern_data) # Internal methods def _get_data_type_from_pattern_type(self, pattern_type: PatternType) -> type: if pattern_type == PatternType.array2D: return Array2DPatternData elif pattern_type == PatternType.gaussian: return GaussianPatternData elif pattern_type == PatternType.doughnut: return DoughnutPatternData elif pattern_type == PatternType.airy_from_FWHM: return AiryFWHMPatternData elif pattern_type == PatternType.airy_from_NA: return AiryNAPatternData elif pattern_type == PatternType.digital_pinhole: return DigitalPinholePatternData elif pattern_type == PatternType.physical_pinhole: return PhysicalPinholePatternData else: raise ValueError(f"Invalid pattern type \"{pattern_type}\"") def _get_pattern_type_from_data(self, pattern_data: PatternData) -> PatternType: if type(pattern_data) is Array2DPatternData: return PatternType.array2D elif type(pattern_data) is GaussianPatternData: return PatternType.gaussian elif type(pattern_data) is DoughnutPatternData: return PatternType.doughnut elif type(pattern_data) is AiryFWHMPatternData: return PatternType.airy_from_FWHM elif type(pattern_data) is AiryNAPatternData: return PatternType.airy_from_NA elif type(pattern_data) is DigitalPinholePatternData: return PatternType.digital_pinhole elif type(pattern_data) is PhysicalPinholePatternData: return PatternType.physical_pinhole else: raise TypeError(f"Invalid pattern data type \"{type(pattern_data).__name__}\"")
class RunInstance: """ A description of all parameters part of a simulation that can be run. """ fluorophore_settings: FluorophoreSettings = extended_field( observable_property("_fluorophore_settings", default=FluorophoreSettings, signal_name="fluorophore_settings_loaded", emit_arg_name="fluorophore_settings"), description="fluorophore settings") imaging_system_settings: ImagingSystemSettings = extended_field( observable_property("_imaging_system_settings", default=ImagingSystemSettings, signal_name="imaging_system_settings_loaded", emit_arg_name="imaging_system_settings"), description="imaging system settings") pulse_scheme: PulseScheme = extended_field(observable_property( "_pulse_scheme", default=PulseScheme, signal_name="pulse_scheme_loaded", emit_arg_name="pulse_scheme"), description="pulse scheme") sample_properties: SampleProperties = extended_field( observable_property("_sample_properties", default=SampleProperties, signal_name="sample_properties_loaded", emit_arg_name="sample_properties"), description="sample properties") detector_properties: DetectorProperties = extended_field( observable_property("_detector_properties", default=DetectorProperties, signal_name="detector_properties_loaded", emit_arg_name="detector_properties"), description="detector properties") simulation_settings: SimulationSettings = extended_field( observable_property("_simulation_settings", default=SimulationSettings, signal_name="simulation_settings_loaded", emit_arg_name="simulation_settings"), description="simulation settings") # Methods def simulate(self, *, cache_kernels2d: bool = True, precache_frc_curves: bool = True, preprocessing_finished_callback: Optional[Signal] = None, progress_updated_callback: Optional[Signal] = None): """ Runs the simulation and returns the results. """ return simulate( deepcopy(self), cache_kernels2d=cache_kernels2d, precache_frc_curves=precache_frc_curves, abort_signal=self._abort_signal, preprocessing_finished_callback=preprocessing_finished_callback, progress_updated_callback=progress_updated_callback) def abort_running_simulations(self) -> None: """ Emits a signal to abort any running simulations. Note that the simulations may not terminate immediately after this method returns. """ self._abort_signal.emit()
class PulseScheme: """ A description of a laser pulse scheme. """ pulses: List[Pulse] = extended_field( dataclass_property( fget=lambda self: self._get_pulses(), fset=lambda self, pulses: self._set_pulses(pulses), default=list ), description=lambda _self, index: f"Pulse #{index + 1}" ) # Methods def add_pulse(self, pulse: Pulse) -> None: """ Adds a pulse to the pulse scheme. """ key = str(uuid.uuid4()) # Generate random key self._pulses[key] = pulse self.pulse_added.emit(key, pulse) def remove_pulse(self, key: str) -> None: """ Removes the pulse with the specified key from the pulse scheme. """ removed_pulse = self._pulses.pop(key) self.pulse_removed.emit(key, removed_pulse) def clear_pulses(self) -> None: """ Removes all pulses from the pulse scheme. """ keys = [*self._pulses.keys()] for key in keys: self.remove_pulse(key) def move_pulse_left(self, key: str) -> None: """ Moves the pulse with the specified key one step to the left in the order. """ existing_keys = [*self._pulses.keys()].copy() i = len(existing_keys) - 1 while i >= 0: has_moved_requested = False if key == existing_keys[i] and 0 < i: self._pulses.move_to_end(existing_keys[i - 1], last=False) has_moved_requested = True self._pulses.move_to_end(existing_keys[i], last=False) if has_moved_requested: i -= 2 else: i -= 1 self.pulse_moved.emit(key, self._pulses[key]) def move_pulse_right(self, key: str) -> None: """ Moves the pulse with the specified key one step to the right in the order. """ existing_keys = [*self._pulses.keys()].copy() i = 0 while i < len(existing_keys): has_moved_requested = False if key == existing_keys[i] and len(existing_keys) > i + 1: self._pulses.move_to_end(existing_keys[i + 1], last=True) has_moved_requested = True self._pulses.move_to_end(existing_keys[i], last=True) if has_moved_requested: i += 2 else: i += 1 self.pulse_moved.emit(key, self._pulses[key]) def get_pulses_with_keys(self): return self._pulses.items() # Internal methods def _get_pulses(self) -> List[Pulse]: return [*self._pulses.values()] def _set_pulses(self, pulses: List[Pulse]) -> None: if not isinstance(pulses, list): return self.clear_pulses() for pulse in pulses: self.add_pulse(pulse)