diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 6fc70b546..c31a577df 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -31,7 +31,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install pytest pytest-cov . + python -m pip install pytest pytest-cov pytest-mock . - name: Test with pytest and coverage badge run: | diff --git a/artist/field/actuator.py b/artist/field/actuator.py index 2bb535456..6e91daed9 100644 --- a/artist/field/actuator.py +++ b/artist/field/actuator.py @@ -11,7 +11,7 @@ class Actuator(torch.nn.Module): ---------- joint_number : int Descriptor (number) of the joint. - clockwise : bool + clockwise_axis_movement : bool Turning direction of the joint. increment : torch.Tensor The stroke length change per motor step. @@ -20,30 +20,30 @@ class Actuator(torch.nn.Module): offset : torch.Tensor The offset between the linear actuator's pivoting point and the point around which the actuator is allowed to pivot. - radius : torch.Tensor + pivot_radius : torch.Tensor The actuator's pivoting radius. - phi_0 : torch.Tensor + initial_angle : torch.Tensor The angle that the actuator introduces to the manipulated coordinate system at the initial stroke length. Methods ------- + motor_position_to_angle() + Calculate the joint angle for a given motor position. + angle_to_motor_position() + Calculate the motor position for a given angle. forward() - The forward kinematic. - motor_steps_to_angles() - Translate motor steps to a joint angle. - angles_to_motor_steps() - Translate a joint angle to motor steps. + Specify the forward pass. """ def __init__( self, joint_number: int, - clockwise: bool, + clockwise_axis_movement: bool, increment: torch.Tensor, initial_stroke_length: torch.Tensor, offset: torch.Tensor, - radius: torch.Tensor, - phi_0: torch.Tensor, + pivot_radius: torch.Tensor, + initial_angle: torch.Tensor, ) -> None: """ Initialize an abstract actuator. @@ -59,7 +59,7 @@ def __init__( ---------- joint_number : int Descriptor (number) of the joint. - clockwise : bool + clockwise_axis_movement : bool Turning direction of the joint. increment : torch.Tensor The stroke length change per motor step. @@ -68,30 +68,30 @@ def __init__( offset : torch.Tensor The offset between the linear actuator's pivoting point and the point around which the actuator is allowed to pivot. - radius : torch.Tensor + pivot_radius : torch.Tensor The actuator's pivoting radius. - phi_0 : torch.Tensor + initial_angle : torch.Tensor The angle that the actuator introduces to the manipulated coordinate system at the initial stroke length. """ super().__init__() self.joint_number = joint_number - self.clockwise = clockwise + self.clockwise_axis_movement = clockwise_axis_movement self.increment = increment self.initial_stroke_length = initial_stroke_length self.offset = offset - self.radius = radius - self.phi_0 = phi_0 + self.pivot_radius = pivot_radius + self.initial_angle = initial_angle - def motor_steps_to_angles( - self, motor_steps: torch.Tensor, device: Union[torch.device, str] = "cuda" + def motor_position_to_angle( + self, motor_position: torch.Tensor, device: Union[torch.device, str] = "cuda" ) -> torch.Tensor: """ - Translate motor steps to a joint angle. + Calculate the joint angle for a given motor position. Parameters ---------- - motor_steps : torch.Tensor - The motor steps. + motor_position : torch.Tensor + The motor position. device : Union[torch.device, str] The device on which to initialize tensors (default is cuda). @@ -102,16 +102,16 @@ def motor_steps_to_angles( """ raise NotImplementedError("Must be overridden!") - def angles_to_motor_steps( - self, angles: torch.Tensor, device: Union[torch.device, str] = "cuda" + def angle_to_motor_position( + self, angle: torch.Tensor, device: Union[torch.device, str] = "cuda" ) -> torch.Tensor: """ - Translate a joint angle to motor steps. + Calculate the motor position for a given angle. Parameters ---------- - angles : torch.Tensor - The joint angles. + angle : torch.Tensor + The joint angle. device : Union[torch.device, str] The device on which to initialize tensors (default is cuda). @@ -122,22 +122,13 @@ def angles_to_motor_steps( """ raise NotImplementedError("Must be overridden!") - def forward( - self, actuator_pos: torch.Tensor, device: Union[torch.device, str] = "cuda" - ) -> torch.Tensor: + def forward(self) -> None: """ - Perform forward kinematic. - - Parameters - ---------- - actuator_pos : torch.Tensor - The position of the actuator. - device : Union[torch.device, str] - The device on which to initialize tensors (default is cuda). + Specify the forward pass. Raises ------ NotImplementedError - This abstract method must be overridden. + Whenever called. """ raise NotImplementedError("Must be overridden!") diff --git a/artist/field/actuator_array.py b/artist/field/actuator_array.py index 5bb33cc78..9ede2e77c 100644 --- a/artist/field/actuator_array.py +++ b/artist/field/actuator_array.py @@ -26,6 +26,11 @@ class ActuatorArray(torch.nn.Module): ---------- actuator_list : List[Actuator] The list of actuators to be wrapped. + + Methods + ------- + forward() + Specify the forward pass. """ def __init__( @@ -56,19 +61,19 @@ def __init__( # Try to load an actuator from the given configuration. This will fail, if ARTIST # does not recognize the actuator type defined in the configuration. try: - actuator_object = actuator_type_mapping[actuator_config.actuator_type] + actuator_object = actuator_type_mapping[actuator_config.type] # Check if the actuator configuration contains actuator parameters and initialize an actuator with # these parameters. - if actuator_config.actuator_parameters is not None: + if actuator_config.parameters is not None: actuator_array.append( actuator_object( joint_number=i + 1, - clockwise=actuator_config.actuator_clockwise, - increment=actuator_config.actuator_parameters.increment, - initial_stroke_length=actuator_config.actuator_parameters.initial_stroke_length, - offset=actuator_config.actuator_parameters.offset, - radius=actuator_config.actuator_parameters.radius, - phi_0=actuator_config.actuator_parameters.phi_0, + clockwise_axis_movement=actuator_config.clockwise_axis_movement, + increment=actuator_config.parameters.increment, + initial_stroke_length=actuator_config.parameters.initial_stroke_length, + offset=actuator_config.parameters.offset, + pivot_radius=actuator_config.parameters.pivot_radius, + initial_angle=actuator_config.parameters.initial_angle, ) ) # If the actuator config does not contain actuator parameters, initialize an actuator with default @@ -80,17 +85,28 @@ def __init__( actuator_array.append( actuator_object( joint_number=i + 1, - clockwise=actuator_config.actuator_clockwise, + clockwise_axis_movement=actuator_config.clockwise_axis_movement, increment=torch.tensor(0.0, device=device), initial_stroke_length=torch.tensor(0.0, device=device), offset=torch.tensor(0.0, device=device), - radius=torch.tensor(0.0, device=device), - phi_0=torch.tensor(0.0, device=device), + pivot_radius=torch.tensor(0.0, device=device), + initial_angle=torch.tensor(0.0, device=device), ) ) except KeyError: raise KeyError( - f"Currently the selected actuator type: {actuator_config.actuator_type} is not supported." + f"Currently the selected actuator type: {actuator_config.type} is not supported." ) self.actuator_list = actuator_array + + def forward(self) -> None: + """ + Specify the forward pass. + + Raises + ------ + NotImplementedError + Whenever called. + """ + raise NotImplementedError("Not Implemented!") diff --git a/artist/field/actuator_ideal.py b/artist/field/actuator_ideal.py index d9c5c5cb1..fa1d5f934 100644 --- a/artist/field/actuator_ideal.py +++ b/artist/field/actuator_ideal.py @@ -13,48 +13,48 @@ class IdealActuator(Actuator): Methods ------- - motor_steps_to_angles() - Calculate the angles given motor steps. - angles_to_motor_steps() - Calculate the motor steps given the angles. + motor_position_to_angle() + Calculate the joint angle for a given motor position. + angle_to_motor_position() + Calculate the motor position for a given angle. forward() - Perform the forward kinematic. + Specify the forward pass. See Also -------- :class:`Actuator` : The parent class. """ - def motor_steps_to_angles( - self, motor_steps: torch.Tensor, device: Union[torch.device, str] = "cuda" + def motor_position_to_angle( + self, motor_position: torch.Tensor, device: Union[torch.device, str] = "cuda" ) -> torch.Tensor: """ - Translate motor steps to a joint angle. + Calculate the joint angle for a given motor position. Parameters ---------- - motor_steps : torch.Tensor - The motor steps. + motor_position : torch.Tensor + The motor position. device : Union[torch.device, str] The device on which to initialize tensors (default is cuda). Returns ------- torch.Tensor - The joint angle. + The joint angle corresponding to the motor position. """ - return motor_steps + return motor_position - def angles_to_motor_steps( - self, angles: torch.Tensor, device: Union[torch.device, str] = "cuda" + def angle_to_motor_position( + self, angle: torch.Tensor, device: Union[torch.device, str] = "cuda" ) -> torch.Tensor: """ - Translate a joint angle to motor steps. + Calculate the motor position for a given angle. Parameters ---------- - angles : torch.Tensor - The joint angles. + angle : torch.Tensor + The joint angle. device : Union[torch.device, str] The device on which to initialize tensors (default is cuda). @@ -63,24 +63,15 @@ def angles_to_motor_steps( torch.Tensor The motor steps. """ - return angles + return angle - def forward( - self, actuator_pos: torch.Tensor, device: Union[torch.device, str] = "cuda" - ) -> torch.Tensor: + def forward(self) -> None: """ - Perform the forward kinematic for an ideal actuator. - - Parameters - ---------- - actuator_pos : torch.Tensor - The position of the actuator. - device : Union[torch.device, str] - The device on which to initialize tensors (default is cuda). + Specify the forward pass. - Returns - ------- - torch.Tensor - The required angles. + Raises + ------ + NotImplementedError + Whenever called. """ - return actuator_pos + raise NotImplementedError("Not Implemented!") diff --git a/artist/field/actuator_linear.py b/artist/field/actuator_linear.py index 966beac30..31e2d66b2 100644 --- a/artist/field/actuator_linear.py +++ b/artist/field/actuator_linear.py @@ -13,7 +13,7 @@ class LinearActuator(Actuator): ---------- joint_number : int Descriptor (number) of the joint. - clockwise : bool + clockwise_axis_movement : bool Turning direction of the joint. increment : torch.Tensor The stroke length change per motor step. @@ -22,21 +22,19 @@ class LinearActuator(Actuator): offset : torch.Tensor The offset between the linear actuator's pivoting point and the point around which the actuator is allowed to pivot. - radius : torch.Tensor + pivot_radius : torch.Tensor The actuator's pivoting radius. - phi_0 : torch.Tensor + initial_angle : torch.Tensor The angle that the actuator introduces to the manipulated coordinate system at the initial stroke length. Methods ------- - steps_to_phi() - Calculate phi (angle) from steps. - motor_steps_to_angles() - Calculate the angles given the motor steps. - angles_to_motor_steps() - Calculate the motor steps given the angles. + motor_position_to_angle() + Calculate the joint angle for a given motor position. + angle_to_motor_position() + Calculate the motor position for a given angle. forward() - Perform the forward kinematic. + Specify the forward pass. See Also -------- @@ -46,12 +44,12 @@ class LinearActuator(Actuator): def __init__( self, joint_number: int, - clockwise: bool, + clockwise_axis_movement: bool, increment: torch.Tensor, initial_stroke_length: torch.Tensor, offset: torch.Tensor, - radius: torch.Tensor, - phi_0: torch.Tensor, + pivot_radius: torch.Tensor, + initial_angle: torch.Tensor, ) -> None: """ Initialize a linear actuator. @@ -62,14 +60,14 @@ def __init__( increment, which stores the information about the stroke length change per motor step, the initial stroke length, and an offset that describes the difference between the linear actuator's pivoting point and the point around which the actuator is allowed to pivot. Next, the actuator's pivoting radius is described by - the radius and lastly, phi_0 indicates the angle that the actuator introduces to the manipulated coordinate - system at the initial stroke length. + the pivot radius and lastly, the initial angle indicates the angle that the actuator introduces to the + manipulated coordinate system at the initial stroke length. Parameters ---------- joint_number : int Descriptor (number) of the joint. - clockwise : bool + clockwise_axis_movement : bool Turning direction of the joint. increment : torch.Tensor The stroke length change per motor step. @@ -78,87 +76,107 @@ def __init__( offset : torch.Tensor The offset between the linear actuator's pivoting point and the point around which the actuator is allowed to pivot. - radius : torch.Tensor + pivot_radius : torch.Tensor The actuator's pivoting radius. - phi_0 : torch.Tensor + initial_angle : torch.Tensor The angle that the actuator introduces to the manipulated coordinate system at the initial stroke length. """ super().__init__( joint_number, - clockwise, + clockwise_axis_movement, increment, initial_stroke_length, offset, - radius, - phi_0, + pivot_radius, + initial_angle, ) self.joint_number = joint_number - self.clockwise = clockwise + self.clockwise_axis_movement = clockwise_axis_movement self.increment = increment self.initial_stroke_length = initial_stroke_length self.offset = offset - self.radius = radius - self.phi_0 = phi_0 + self.pivot_radius = pivot_radius + self.initial_angle = initial_angle - def steps_to_phi(self, actuator_pos: torch.Tensor) -> torch.Tensor: + def _motor_position_to_absolute_angle( + self, motor_position: torch.Tensor + ) -> torch.Tensor: """ - Calculate phi (angle) from steps. + Convert motor steps into an angle using actuator geometry. + + Calculate an absolute angle based solely on the motor's current position and the geometry + of the actuator. This gives the angle of the actuator in a global sense. It does not + consider the starting position of the motor. Parameters ---------- - actuator_pos : torch.Tensor - The actuator position. + motor_position : torch.Tensor + The motor position. Returns ------- torch.Tensor - The calculated angle. + The calculated absolute angle. """ - stroke_length = actuator_pos / self.increment + self.initial_stroke_length - calc_step_1 = self.offset**2 + self.radius**2 - stroke_length**2 - calc_step_2 = 2.0 * self.offset * self.radius + stroke_length = motor_position / self.increment + self.initial_stroke_length + calc_step_1 = self.offset**2 + self.pivot_radius**2 - stroke_length**2 + calc_step_2 = 2.0 * self.offset * self.pivot_radius calc_step_3 = calc_step_1 / calc_step_2 - angle = torch.arccos(calc_step_3) - return angle + absolute_angle = torch.arccos(calc_step_3) + return absolute_angle - def motor_steps_to_angles( - self, actuator_pos: torch.Tensor, device: Union[torch.device, str] = "cuda" + def motor_position_to_angle( + self, motor_position: torch.Tensor, device: Union[torch.device, str] = "cuda" ) -> torch.Tensor: """ - Calculate the angles given the motor steps. + Calculate the joint angle for a given motor position. + + Using the absolute angle calculated with _motor_position_to_absolute_angle(), the + absolute angle is adjusted to be relative to an initial angle. It accounts for + the initial angle and the motor's direction (clockwise or counterclockwise). Parameters ---------- - actuator_pos : torch.Tensor - The actuator (motor) position. + motor_position : torch.Tensor + The motor position. device : Union[torch.device, str] The device on which to initialize tensors (default is cuda). Returns ------- torch.Tensor - The angles corresponding to the motor steps. + The joint angle corresponding to the motor position. """ device = torch.device(device) - phi = self.steps_to_phi(actuator_pos=actuator_pos) - phi_0 = self.steps_to_phi( - actuator_pos=torch.zeros(actuator_pos.shape, device=device) + absolute_angle = self._motor_position_to_absolute_angle( + motor_position=motor_position ) - delta_phi = phi_0 - phi + absolute_initial_angle = self._motor_position_to_absolute_angle( + motor_position=torch.zeros(1, device=device) + ) + delta_angle = absolute_initial_angle - absolute_angle - angles = self.phi_0 + delta_phi if self.clockwise else self.phi_0 - delta_phi - return angles + relative_angle = ( + self.initial_angle + delta_angle + if self.clockwise_axis_movement + else self.initial_angle - delta_angle + ) + return relative_angle - def angles_to_motor_steps( - self, angles: torch.Tensor, device: Union[torch.device, str] = "cuda" + def angle_to_motor_position( + self, angle: torch.Tensor, device: Union[torch.device, str] = "cuda" ) -> torch.Tensor: """ - Calculate the motor steps given the angles. + Calculate the motor position for a given angle. + + First the relative angular change is calculated based on the given angle. + Then the corresponding stroke length is determined using trigonometric + relationships. This stroke length is converted into motor steps. Parameters ---------- - angles : torch.Tensor - The angles. + angle : torch.Tensor + The joint angle. device : Union[torch.device, str] The device on which to initialize tensors (default is cuda). @@ -168,38 +186,31 @@ def angles_to_motor_steps( The motor steps. """ device = torch.device(device) - delta_phi = angles - self.phi_0 if self.clockwise else self.phi_0 - angles + delta_angle = ( + angle - self.initial_angle + if self.clockwise_axis_movement + else self.initial_angle - angle + ) - phi_0 = self.steps_to_phi( - actuator_pos=torch.zeros(angles.shape[0], device=device) + absolute_initial_angle = self._motor_position_to_absolute_angle( + motor_position=torch.zeros(1, device=device) ) - phi = phi_0 - delta_phi + initial_angle = absolute_initial_angle - delta_angle - calc_step_3 = torch.cos(phi) - calc_step_2 = 2.0 * self.offset * self.radius + calc_step_3 = torch.cos(initial_angle) + calc_step_2 = 2.0 * self.offset * self.pivot_radius calc_step_1 = calc_step_3 * calc_step_2 - stroke_length = torch.sqrt(self.offset**2 + self.radius**2 - calc_step_1) - actuator_steps = (stroke_length - self.initial_stroke_length) * self.increment - return actuator_steps + stroke_length = torch.sqrt(self.offset**2 + self.pivot_radius**2 - calc_step_1) + motor_position = (stroke_length - self.initial_stroke_length) * self.increment + return motor_position - def forward( - self, actuator_pos: torch.Tensor, device: Union[torch.device, str] = "cuda" - ) -> torch.Tensor: + def forward(self) -> None: """ - Perform the forward kinematic. + Specify the forward pass. - Parameters - ---------- - actuator_pos : torch.Tensor - The actuator (motor) position. - device : Union[torch.device, str] - The device on which to initialize tensors (default is cuda). - - Returns - ------- - torch.Tensor - The angles. + Raises + ------ + NotImplementedError + Whenever called. """ - return self.motor_steps_to_angles( - actuator_pos=actuator_pos, device=torch.device(device) - ) + raise NotImplementedError("Not Implemented!") diff --git a/artist/field/facets_nurbs.py b/artist/field/facets_nurbs.py index b155f1608..6eb9605b9 100644 --- a/artist/field/facets_nurbs.py +++ b/artist/field/facets_nurbs.py @@ -21,16 +21,20 @@ class NurbsFacet(torch.nn.Module): The number of evaluation points for the NURBS surface in the east direction. number_eval_points_n : int The number of evaluation points for the NURBS surface in the north direction. - width : float - The width of the facet. - height : float - The height of the facet. translation_vector : torch.Tensor The translation_vector of the facet. canting_e : torch.Tensor The canting vector in the east direction of the facet. canting_n : torch.Tensor The canting vector in the north direction of the facet. + + + Methods + ------- + create_nurbs_surface() + Create a NURBS surface to model a facet. + forward() + Specify the forward pass. """ def __init__( @@ -40,8 +44,6 @@ def __init__( degree_n: int, number_eval_points_e: int, number_eval_points_n: int, - width: float, - height: float, translation_vector: torch.Tensor, canting_e: torch.Tensor, canting_n: torch.Tensor, @@ -53,9 +55,8 @@ def __init__( Non-Uniform Rational B-Splines (NURBS). Thus, each facet is an individual NURBS surface. The NURBS surface is created by specifying several parameters. For a detailed description of these parameters see the `NURBS-tutorial`. For now, note that the NURBS surfaces can be formed through - control points, two degrees, the number of evaluation points in east and north direction, the - width and height of the heliostat, a translation vector to match the facets to their position, - and canting vectors. + control points, two degrees, the number of evaluation points in east and north direction, a + translation vector to match the facets to their position, and canting vectors. Parameters ---------- @@ -69,10 +70,6 @@ def __init__( The number of evaluation points for the NURBS surface in the east direction. number_eval_points_n : int The number of evaluation points for the NURBS surface in the north direction. - width : float - The width of the facet. - height : float - The height of the facet. translation_vector : torch.Tensor The translation_vector of the facet. canting_e : torch.Tensor @@ -86,8 +83,6 @@ def __init__( self.degree_n = degree_n self.number_eval_points_e = number_eval_points_e self.number_eval_points_n = number_eval_points_n - self.width = width - self.height = height self.translation_vector = translation_vector self.canting_e = canting_e self.canting_n = canting_n @@ -135,3 +130,14 @@ def create_nurbs_surface( device, ) return nurbs_surface + + def forward(self) -> None: + """ + Specify the forward pass. + + Raises + ------ + NotImplementedError + Whenever called. + """ + raise NotImplementedError("Not Implemented!") diff --git a/artist/field/heliostat.py b/artist/field/heliostat.py index f4e918633..9ad357fa7 100644 --- a/artist/field/heliostat.py +++ b/artist/field/heliostat.py @@ -8,7 +8,7 @@ from artist.field.kinematic_rigid_body import RigidBody from artist.field.surface import Surface from artist.raytracing.raytracing_utils import reflect -from artist.util import config_dictionary +from artist.util import config_dictionary, utils from artist.util.configuration_classes import ( ActuatorConfig, ActuatorListConfig, @@ -16,7 +16,6 @@ FacetConfig, KinematicDeviations, KinematicLoadConfig, - KinematicOffsets, SurfaceConfig, ) @@ -51,15 +50,25 @@ class Heliostat(torch.nn.Module): Boolean indicating if the heliostat is aligned. preferred_reflection_direction : torch.Tensor The preferred reflection direction for rays reflecting off the heliostat. + surface_points : torch.Tensor + The original, unaligned surface points. + surface_normals : torch.Tensor + The original, unaligned surface normals. Methods ------- from_hdf5() Class method to initialize a heliostat from an HDF5 file. - set_aligned_surface() + set_aligned_surface_with_incident_ray_direction() Compute the aligned surface points and aligned surface normals of the heliostat. + set_aligned_surface_with_motor_positions() + Compute the aligned surface points and aligned surface normals of the heliostat. + get_orientation_from_motor_positions() + Compute the orientation for a heliostat given the desired motor positions. set_preferred_reflection_direction() - Compute the preferred reflection direction for each normal vector given an incident ray direction. + Reflect incoming rays according to a normal vector. + forward() + Specify the forward pass. """ def __init__( @@ -105,17 +114,17 @@ def __init__( self.aim_point = aim_point self.surface = Surface(surface_config=surface_config) try: - kinematic_object = kinematic_type_mapping[kinematic_config.kinematic_type] + kinematic_object = kinematic_type_mapping[kinematic_config.type] except KeyError: raise KeyError( - f"Currently the selected kinematic type: {kinematic_config.kinematic_type} is not supported." + f"Currently the selected kinematic type: {kinematic_config.type} is not supported." ) self.kinematic = kinematic_object( position=position, aim_point=aim_point, actuator_config=actuator_config, - initial_orientation_offsets=kinematic_config.kinematic_initial_orientation_offsets, - deviation_parameters=kinematic_config.kinematic_deviations, + initial_orientation=kinematic_config.initial_orientation, + deviation_parameters=kinematic_config.deviations, device=device, ) self.current_aligned_surface_points = torch.empty(0, device=device) @@ -123,13 +132,17 @@ def __init__( self.is_aligned = False self.preferred_reflection_direction = torch.empty(0, device=device) + self.surface_points, self.surface_normals = ( + self.surface.get_surface_points_and_normals(device=device) + ) + @classmethod def from_hdf5( cls, config_file: h5py.File, prototype_surface: Optional[SurfaceConfig] = None, prototype_kinematic: Optional[KinematicLoadConfig] = None, - prototype_actuator: Optional[ActuatorListConfig] = None, + prototype_actuator_list: Optional[ActuatorListConfig] = None, heliostat_name: Optional[str] = None, device: Union[torch.device, str] = "cuda", ) -> Self: @@ -144,7 +157,7 @@ def from_hdf5( An optional prototype for the surface configuration. prototype_kinematic : KinematicLoadConfig, optional An optional prototype for the kinematic configuration. - prototype_actuator : ActuatorConfig, optional + prototype_actuator_list : ActuatorListConfig, optional An optional prototype for the actuator configuration. heliostat_name : str, optional The name of the heliostat being loaded - used for logging. @@ -202,16 +215,6 @@ def from_hdf5( config_dictionary.facets_key ][facet][config_dictionary.facet_number_eval_n][()] ), - width=float( - config_file[config_dictionary.heliostat_surface_key][ - config_dictionary.facets_key - ][facet][config_dictionary.facets_width][()] - ), - height=float( - config_file[config_dictionary.heliostat_surface_key][ - config_dictionary.facets_key - ][facet][config_dictionary.facets_height][()] - ), translation_vector=torch.tensor( config_file[config_dictionary.heliostat_surface_key][ config_dictionary.facets_key @@ -240,73 +243,22 @@ def from_hdf5( ] surface_config = SurfaceConfig(facets_list=facets_list) else: - assert ( - prototype_surface is not None - ), "If the heliostat does not have individual surface parameters, a surface prototype must be provided!" + if prototype_surface is None: + raise ValueError( + "If the heliostat does not have individual surface parameters, a surface prototype must be provided!" + ) log.info( "Individual surface parameters not provided - loading a heliostat with the surface prototype." ) surface_config = prototype_surface if config_dictionary.heliostat_kinematic_key in config_file.keys(): - kinematic_initial_orientation_offset_e = config_file.get( - f"{config_dictionary.heliostat_kinematic_key}/" - f"{config_dictionary.kinematic_offsets_key}/{config_dictionary.kinematic_initial_orientation_offset_e}" - ) - kinematic_initial_orientation_offset_n = config_file.get( - f"{config_dictionary.heliostat_kinematic_key}/" - f"{config_dictionary.kinematic_offsets_key}/{config_dictionary.kinematic_initial_orientation_offset_n}" - ) - kinematic_initial_orientation_offset_u = config_file.get( - f"{config_dictionary.heliostat_kinematic_key}/" - f"{config_dictionary.kinematic_offsets_key}/{config_dictionary.kinematic_initial_orientation_offset_n}" - ) - if kinematic_initial_orientation_offset_e is None: - log.warning( - f"No individual kinematic {config_dictionary.kinematic_initial_orientation_offset_e} for " - f"{heliostat_name} set." - f"Using default values!" - ) - if kinematic_initial_orientation_offset_n is None: - log.warning( - f"No individual kinematic {config_dictionary.kinematic_initial_orientation_offset_n} for " - f"{heliostat_name} set." - f"Using default values!" - ) - if kinematic_initial_orientation_offset_u is None: - log.warning( - f"No individual kinematic {config_dictionary.kinematic_initial_orientation_offset_u} for " - f"{heliostat_name} set." - f"Using default values!" - ) - kinematic_offsets = KinematicOffsets( - kinematic_initial_orientation_offset_e=( - torch.tensor( - kinematic_initial_orientation_offset_e[()], - dtype=torch.float, - device=device, - ) - if kinematic_initial_orientation_offset_e - else torch.tensor(0.0, dtype=torch.float, device=device) - ), - kinematic_initial_orientation_offset_n=( - torch.tensor( - kinematic_initial_orientation_offset_n[()], - dtype=torch.float, - device=device, - ) - if kinematic_initial_orientation_offset_n - else torch.tensor(0.0, dtype=torch.float, device=device) - ), - kinematic_initial_orientation_offset_u=( - torch.tensor( - kinematic_initial_orientation_offset_u[()], - dtype=torch.float, - device=device, - ) - if kinematic_initial_orientation_offset_u - else torch.tensor(0.0, dtype=torch.float, device=device) - ), + initial_orientation = torch.tensor( + config_file[config_dictionary.heliostat_kinematic_key][ + config_dictionary.kinematic_initial_orientation + ][()], + dtype=torch.float, + device=device, ) first_joint_translation_e = config_file.get( @@ -618,18 +570,19 @@ def from_hdf5( ), ) kinematic_config = KinematicLoadConfig( - kinematic_type=str( + type=str( config_file[config_dictionary.heliostat_kinematic_key][ config_dictionary.kinematic_type ][()].decode("utf-8") ), - kinematic_initial_orientation_offsets=kinematic_offsets, - kinematic_deviations=kinematic_deviations, + initial_orientation=initial_orientation, + deviations=kinematic_deviations, ) else: - assert ( - prototype_kinematic is not None - ), "If the heliostat does not have an individual kinematic, a kinematic prototype must be provided!" + if prototype_kinematic is None: + raise ValueError( + "If the heliostat does not have an individual kinematic, a kinematic prototype must be provided!" + ) log.info( "Individual kinematic configuration not provided - loading a heliostat with the kinematic prototype." ) @@ -653,15 +606,15 @@ def from_hdf5( f"{config_dictionary.actuator_parameters_key}/" f"{config_dictionary.actuator_offset}" ) - radius = config_file.get( + pivot_radius = config_file.get( f"{config_dictionary.heliostat_actuator_key}/{ac}/" f"{config_dictionary.actuator_parameters_key}/" - f"{config_dictionary.actuator_radius}" + f"{config_dictionary.actuator_pivot_radius}" ) - phi_0 = config_file.get( + initial_angle = config_file.get( f"{config_dictionary.heliostat_actuator_key}/{ac}/" f"{config_dictionary.actuator_parameters_key}/" - f"{config_dictionary.actuator_phi_0}" + f"{config_dictionary.actuator_initial_angle}" ) if increment is None: log.warning( @@ -678,14 +631,14 @@ def from_hdf5( f"No individual {config_dictionary.actuator_offset} set for {ac} on " f"{heliostat_name}. Using default values!" ) - if radius is None: + if pivot_radius is None: log.warning( - f"No individual {config_dictionary.actuator_radius} set for {ac} on " + f"No individual {config_dictionary.actuator_pivot_radius} set for {ac} on " f"{heliostat_name}. Using default values!" ) - if phi_0 is None: + if initial_angle is None: log.warning( - f"No individual {config_dictionary.actuator_phi_0} set for {ac} on " + f"No individual {config_dictionary.actuator_initial_angle} set for {ac} on " f"{heliostat_name}. Using default values!" ) actuator_parameters = ActuatorParameters( @@ -706,42 +659,65 @@ def from_hdf5( if offset else torch.tensor(0.0, dtype=torch.float, device=device) ), - radius=( - torch.tensor(radius[()], dtype=torch.float, device=device) - if radius + pivot_radius=( + torch.tensor(pivot_radius[()], dtype=torch.float, device=device) + if pivot_radius else torch.tensor(0.0, dtype=torch.float, device=device) ), - phi_0=( - torch.tensor(phi_0[()], dtype=torch.float, device=device) - if phi_0 + initial_angle=( + torch.tensor( + initial_angle[()], dtype=torch.float, device=device + ) + if initial_angle else torch.tensor(0.0, dtype=torch.float, device=device) ), ) actuator_list.append( ActuatorConfig( - actuator_key="", - actuator_type=str( + key="", + type=str( config_file[config_dictionary.heliostat_actuator_key][ac][ config_dictionary.actuator_type_key ][()].decode("utf-8") ), - actuator_clockwise=bool( + clockwise_axis_movement=bool( config_file[config_dictionary.heliostat_actuator_key][ac][ - config_dictionary.actuator_clockwise + config_dictionary.actuator_clockwise_axis_movement ][()] ), - actuator_parameters=actuator_parameters, + parameters=actuator_parameters, ) ) actuator_list_config = ActuatorListConfig(actuator_list=actuator_list) else: - assert ( - prototype_actuator is not None - ), "If the heliostat does not have individual actuators, an actuator prototype must be provided!" + if prototype_actuator_list is None: + raise ValueError( + "If the heliostat does not have individual actuators, an actuator prototype must be provided!" + ) log.info( "Individual actuator configurations not provided - loading a heliostat with the actuator prototype." ) - actuator_list_config = prototype_actuator + actuator_list_config = prototype_actuator_list + + # Adapt initial angle of actuator one according to kinematic initial orientation. + # ARTIST always expects heliostats to be initially oriented to the south [0.0, -1.0, 0.0] (in ENU). + # The first actuator always rotates along the east-axis. + # Since the actuator coordinate system is relative to the heliostat orientation, the initial angle + # of actuator one needs to be transformed accordingly. + if actuator_list_config.actuator_list[0].parameters: + initial_angle = actuator_list_config.actuator_list[ + 0 + ].parameters.initial_angle + + transformed_initial_angle = utils.transform_initial_angle( + initial_angle=initial_angle, + initial_orientation=kinematic_config.initial_orientation, + device=device, + ) + + actuator_list_config.actuator_list[ + 0 + ].parameters.initial_angle = transformed_initial_angle return cls( heliostat_id=heliostat_id, @@ -753,7 +729,7 @@ def from_hdf5( device=device, ) - def set_aligned_surface( + def set_aligned_surface_with_incident_ray_direction( self, incident_ray_direction: torch.Tensor, device: Union[torch.device, str] = "cuda", @@ -761,6 +737,8 @@ def set_aligned_surface( """ Compute the aligned surface points and aligned surface normals of the heliostat. + This method uses the incident ray direction to align the heliostat. + Parameters ---------- incident_ray_direction : torch.Tensor @@ -769,18 +747,63 @@ def set_aligned_surface( The device on which to initialize tensors (default is cuda). """ device = torch.device(device) - - surface_points, surface_normals = self.surface.get_surface_points_and_normals( - device=device + ( + self.current_aligned_surface_points, + self.current_aligned_surface_normals, + ) = self.kinematic.align_surface_with_incident_ray_direction( + incident_ray_direction, self.surface_points, self.surface_normals, device ) + self.is_aligned = True + + def set_aligned_surface_with_motor_positions( + self, + motor_positions: torch.Tensor, + device: Union[torch.device, str] = "cuda", + ) -> None: + """ + Compute the aligned surface points and aligned surface normals of the heliostat. + + This method uses the motor positions to align the heliostat. + + Parameters + ---------- + motor_positions : torch.Tensor + The motor positions. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + """ + device = torch.device(device) ( self.current_aligned_surface_points, self.current_aligned_surface_normals, - ) = self.kinematic.align_surface( - incident_ray_direction, surface_points, surface_normals, device + ) = self.kinematic.align_surface_with_motor_positions( + motor_positions, self.surface_points, self.surface_normals, device ) self.is_aligned = True + def get_orientation_from_motor_positions( + self, + motor_positions: torch.Tensor, + device: Union[torch.device, str] = "cuda", + ) -> torch.Tensor: + """ + Compute the orientation for a heliostat given the desired motor positions. + + Parameters + ---------- + motor_positions : torch.Tensor + The desired motor positions. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + torch.Tensor + The orientation for the given motor position. + """ + device = torch.device(device) + return self.kinematic.motor_positions_to_orientation(motor_positions, device) + def set_preferred_reflection_direction(self, rays: torch.Tensor) -> None: """ Reflect incoming rays according to a normal vector. @@ -792,12 +815,23 @@ def set_preferred_reflection_direction(self, rays: torch.Tensor) -> None: Raises ------ - AssertionError + ValueError If the heliostat has not yet been aligned. """ - assert self.is_aligned, "Heliostat has not yet been aligned." - + if not self.is_aligned: + raise ValueError("Heliostat has not yet been aligned.") self.preferred_reflection_direction = reflect( incoming_ray_direction=rays, reflection_surface_normals=self.current_aligned_surface_normals, ) + + def forward(self) -> None: + """ + Specify the forward pass. + + Raises + ------ + NotImplementedError + Whenever called. + """ + raise NotImplementedError("Not Implemented!") diff --git a/artist/field/heliostat_field.py b/artist/field/heliostat_field.py index 7bcf6d8c1..f071f5e5f 100644 --- a/artist/field/heliostat_field.py +++ b/artist/field/heliostat_field.py @@ -30,6 +30,8 @@ class HeliostatField(torch.nn.Module): ------- from_hdf5() Load the list of heliostats from an HDF5 file. + forward() + Specify the forward pass. """ def __init__(self, heliostat_list: list[Heliostat]): @@ -87,10 +89,21 @@ def from_hdf5( ], prototype_surface=prototype_surface, prototype_kinematic=prototype_kinematic, - prototype_actuator=prototype_actuator, + prototype_actuator_list=prototype_actuator, heliostat_name=heliostat_name, device=device, ) for heliostat_name in config_file[config_dictionary.heliostat_key].keys() ] return cls(heliostat_list=heliostat_list) + + def forward(self) -> None: + """ + Specify the forward pass. + + Raises + ------ + NotImplementedError + Whenever called. + """ + raise NotImplementedError("Not Implemented!") diff --git a/artist/field/kinematic.py b/artist/field/kinematic.py index b160a455c..b8dde914f 100644 --- a/artist/field/kinematic.py +++ b/artist/field/kinematic.py @@ -19,7 +19,7 @@ class Kinematic(torch.nn.Module): align() Compute the orientation matrix to align the heliostat. forward() - Implement the forward kinematics. + Specify the forward pass. """ def __init__(self, position: torch.Tensor, aim_point: torch.Tensor) -> None: @@ -67,13 +67,13 @@ def align( """ raise NotImplementedError("Must be overridden!") - def forward(self) -> torch.Tensor: + def forward(self) -> None: """ - Implement the forward kinematics. + Specify the forward pass. Raises ------ NotImplementedError - Whenever called (abstract base class method). + Whenever called. """ raise NotImplementedError("Must be overridden!") diff --git a/artist/field/kinematic_rigid_body.py b/artist/field/kinematic_rigid_body.py index ec6a80fa7..3f1c02ed0 100644 --- a/artist/field/kinematic_rigid_body.py +++ b/artist/field/kinematic_rigid_body.py @@ -10,7 +10,6 @@ from artist.util.configuration_classes import ( ActuatorListConfig, KinematicDeviations, - KinematicOffsets, ) @@ -22,17 +21,25 @@ class RigidBody(Kinematic): ---------- deviation_parameters : KinematicDeviations 18 deviation parameters describing imperfections in the heliostat. - initial_orientation_offsets : KinematicOffsets + initial_orientation : torch.Tensor The initial orientation-rotation angles of the heliostat. actuators : ActuatorArray The actuators required for the kinematic. + artist_standard_orientation : torch.Tensor + The standard orientation of the kinematic. Methods ------- - align() - Compute the rotation matrix to align the concentrator along a desired orientation. - align_surface() - Align given surface points and surface normals according to a calculated orientation. + incident_ray_direction_to_orientation() + Compute the orientation matrix given an incident ray direction. + align_surface_with_incident_ray_direction() + Align given surface points and surface normals according to an incident ray direction. + motor_positions_to_orientation() + Compute the orientation matrix given the motor positions. + align_surface_with_motor_positions() + Align given surface points and surface normals according to motor positions. + forward() + Specify the forward pass. See Also -------- @@ -44,11 +51,7 @@ def __init__( position: torch.Tensor, aim_point: torch.Tensor, actuator_config: ActuatorListConfig, - initial_orientation_offsets: KinematicOffsets = KinematicOffsets( - kinematic_initial_orientation_offset_e=torch.tensor(0.0), - kinematic_initial_orientation_offset_n=torch.tensor(0.0), - kinematic_initial_orientation_offset_u=torch.tensor(0.0), - ), + initial_orientation: torch.Tensor, deviation_parameters: KinematicDeviations = KinematicDeviations( first_joint_translation_e=torch.tensor(0.0), first_joint_translation_n=torch.tensor(0.0), @@ -101,18 +104,15 @@ def __init__( if isinstance(attr_value, torch.Tensor): setattr(self.deviation_parameters, attr_name, attr_value.to(device)) - self.initial_orientation_offsets = initial_orientation_offsets - for attr_name, attr_value in self.initial_orientation_offsets.__dict__.items(): - if isinstance(attr_value, torch.Tensor): - setattr( - self.initial_orientation_offsets, attr_name, attr_value.to(device) - ) - self.actuators = ActuatorArray( actuator_list_config=actuator_config, device=device ) + self.initial_orientation_helisotat = initial_orientation + self.artist_standard_orientation = torch.tensor( + [0.0, -1.0, 0.0, 0.0], device=device + ) - def align( + def incident_ray_direction_to_orientation( self, incident_ray_direction: torch.Tensor, max_num_iterations: int = 2, @@ -120,7 +120,7 @@ def align( device: Union[torch.device, str] = "cuda", ) -> torch.Tensor: """ - Compute the rotation matrix to align the heliostat along a desired orientation. + Compute the orientation matrix given an incident ray direction. Parameters ---------- @@ -138,36 +138,33 @@ def align( torch.Tensor The orientation matrix. """ - assert ( - len(self.actuators.actuator_list) == 2 - ), "The rigid body kinematic requires exactly two actuators, please check the configuration!" + if len(self.actuators.actuator_list) != 2: + raise ValueError( + f"The rigid body kinematic requires exactly two actuators but {len(self.actuators.actuator_list)} were specified, please check the configuration!" + ) + device = torch.device(device) - actuator_steps = torch.zeros((1, 2), requires_grad=True, device=device) - orientation = None + motor_positions = torch.zeros(2, device=device) last_iteration_loss = None for _ in range(max_num_iterations): - joint_1_angles = self.actuators.actuator_list[0]( - actuator_pos=actuator_steps[:, 0], device=device + joint_1_angle = self.actuators.actuator_list[0].motor_position_to_angle( + motor_position=motor_positions[0], device=device ) - joint_2_angles = self.actuators.actuator_list[1]( - actuator_pos=actuator_steps[:, 1], device=device + joint_2_angle = self.actuators.actuator_list[1].motor_position_to_angle( + motor_position=motor_positions[1], device=device ) - initial_orientations = ( - torch.eye(4, device=device) - .unsqueeze(0) - .repeat(len(joint_1_angles), 1, 1) - ) + initial_orientation = torch.eye(4, device=device) # Account for position. - initial_orientations = initial_orientations @ utils.translate_enu( + initial_orientation = initial_orientation @ utils.translate_enu( e=self.position[0], n=self.position[1], u=self.position[2], device=device, ) - joint_1_rotations = ( + joint_1_rotation = ( utils.rotate_n( n=self.deviation_parameters.first_joint_tilt_n, device=device ) @@ -180,9 +177,9 @@ def align( u=self.deviation_parameters.first_joint_translation_u, device=device, ) - @ utils.rotate_e(joint_1_angles, device=device) + @ utils.rotate_e(joint_1_angle, device=device) ) - joint_2_rotations = ( + joint_2_rotation = ( utils.rotate_e( e=self.deviation_parameters.second_joint_tilt_e, device=device ) @@ -195,13 +192,13 @@ def align( u=self.deviation_parameters.second_joint_translation_u, device=device, ) - @ utils.rotate_u(joint_2_angles, device=device) + @ utils.rotate_u(joint_2_angle, device=device) ) orientation = ( - initial_orientations - @ joint_1_rotations - @ joint_2_rotations + initial_orientation + @ joint_1_rotation + @ joint_2_rotation @ utils.translate_enu( e=self.deviation_parameters.concentrator_translation_e, n=self.deviation_parameters.concentrator_translation_n, @@ -210,22 +207,26 @@ def align( ) ) - concentrator_normals = orientation @ torch.tensor( + concentrator_normal = orientation @ torch.tensor( [0, -1, 0, 0], dtype=torch.float32, device=device ) - concentrator_origins = orientation @ torch.tensor( + concentrator_origin = orientation @ torch.tensor( [0, 0, 0, 1], dtype=torch.float32, device=device ) # Compute desired normal. - desired_reflect_vec = self.aim_point - concentrator_origins - desired_reflect_vec /= desired_reflect_vec.norm() - incident_ray_direction /= incident_ray_direction.norm() + desired_reflect_vec = self.aim_point - concentrator_origin + desired_reflect_vec = desired_reflect_vec / desired_reflect_vec.norm() + incident_ray_direction = ( + incident_ray_direction / incident_ray_direction.norm() + ) desired_concentrator_normal = incident_ray_direction + desired_reflect_vec - desired_concentrator_normal /= desired_concentrator_normal.norm() + desired_concentrator_normal = ( + desired_concentrator_normal / desired_concentrator_normal.norm() + ) # Compute epoch loss. - loss = torch.abs(desired_concentrator_normal - concentrator_normals) + loss = torch.abs(desired_concentrator_normal - concentrator_normal) # Stop if converged. if isinstance(last_iteration_loss, torch.Tensor): @@ -237,82 +238,72 @@ def align( # Analytical Solution # Calculate joint 2 angle. - joint_2_angles = -torch.arcsin( - -desired_concentrator_normal[:, 0] + joint_2_angle = -torch.arcsin( + -desired_concentrator_normal[0] / torch.cos(self.deviation_parameters.second_joint_translation_n) ) # Calculate joint 1 angle. a = -torch.cos( self.deviation_parameters.second_joint_translation_e - ) * torch.cos(joint_2_angles) + torch.sin( + ) * torch.cos(joint_2_angle) + torch.sin( self.deviation_parameters.second_joint_translation_e ) * torch.sin( self.deviation_parameters.second_joint_translation_n - ) * torch.sin(joint_2_angles) + ) * torch.sin(joint_2_angle) b = -torch.sin( self.deviation_parameters.second_joint_translation_e - ) * torch.cos(joint_2_angles) - torch.cos( + ) * torch.cos(joint_2_angle) - torch.cos( self.deviation_parameters.second_joint_translation_e ) * torch.sin( self.deviation_parameters.second_joint_translation_n - ) * torch.sin(joint_2_angles) + ) * torch.sin(joint_2_angle) - joint_1_angles = ( + joint_1_angle = ( torch.arctan2( - a * -desired_concentrator_normal[:, 2] - - b * -desired_concentrator_normal[:, 1], - a * -desired_concentrator_normal[:, 1] - + b * -desired_concentrator_normal[:, 2], + a * -desired_concentrator_normal[2] + - b * -desired_concentrator_normal[1], + a * -desired_concentrator_normal[1] + + b * -desired_concentrator_normal[2], ) - torch.pi ) - actuator_steps = torch.stack( + motor_positions = torch.stack( ( - self.actuators.actuator_list[0].angles_to_motor_steps( - joint_1_angles, device + self.actuators.actuator_list[0].angle_to_motor_position( + joint_1_angle, device ), - self.actuators.actuator_list[1].angles_to_motor_steps( - joint_2_angles, device + self.actuators.actuator_list[1].angle_to_motor_position( + joint_2_angle, device ), ), - dim=-1, ) + east_angle, north_angle, up_angle = utils.decompose_rotation( + initial_vector=self.initial_orientation_helisotat[:-1], + target_vector=self.artist_standard_orientation[:-1], + device=device, + ) + # Return orientation matrix multiplied by the initial orientation offset. return ( orientation @ utils.rotate_e( - e=torch.tensor( - [ - self.initial_orientation_offsets.kinematic_initial_orientation_offset_e - ], - device=device, - ), + e=east_angle, device=device, ) @ utils.rotate_n( - n=torch.tensor( - [ - self.initial_orientation_offsets.kinematic_initial_orientation_offset_n - ], - device=device, - ), + n=north_angle, device=device, ) @ utils.rotate_u( - u=torch.tensor( - [ - self.initial_orientation_offsets.kinematic_initial_orientation_offset_u - ], - device=device, - ), + u=up_angle, device=device, ) ) - def align_surface( + def align_surface_with_incident_ray_direction( self, incident_ray_direction: torch.Tensor, surface_points: torch.Tensor, @@ -320,7 +311,7 @@ def align_surface( device: Union[torch.device, str] = "cuda", ) -> tuple[torch.Tensor, torch.Tensor]: """ - Align given surface points and surface normals according to a calculated orientation. + Align given surface points and surface normals according to an incident ray direction. Parameters ---------- @@ -342,13 +333,171 @@ def align_surface( """ device = torch.device(device) - orientation = self.align(incident_ray_direction, device=device).squeeze() + orientation = self.incident_ray_direction_to_orientation( + incident_ray_direction, device=device + ) + + aligned_surface_points = surface_points @ orientation.T + aligned_surface_normals = surface_normals @ orientation.T + + return aligned_surface_points, aligned_surface_normals + + def motor_positions_to_orientation( + self, + motor_positions: torch.Tensor, + device: Union[torch.device, str] = "cuda", + ) -> torch.Tensor: + """ + Compute the orientation matrix given the motor positions. + + Parameters + ---------- + motor_positions : torch.Tensor + The motor positions from the calibration. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + torch.Tensor + The orientation matrix. + """ + if len(self.actuators.actuator_list) != 2: + raise ValueError( + f"The rigid body kinematic requires exactly two actuators but {len(self.actuators.actuator_list)} were specified, please check the configuration!" + ) - aligned_surface_points = (orientation @ surface_points.unsqueeze(-1)).squeeze( - -1 + device = torch.device(device) + + joint_1_angle = self.actuators.actuator_list[0].motor_position_to_angle( + motor_position=motor_positions[0], device=device + ) + joint_2_angle = self.actuators.actuator_list[1].motor_position_to_angle( + motor_position=motor_positions[1], device=device ) - aligned_surface_normals = (orientation @ surface_normals.unsqueeze(-1)).squeeze( - -1 + + initial_orientation = torch.eye(4, device=device) + + # Account for position. + initial_orientation = initial_orientation @ utils.translate_enu( + e=self.position[0], + n=self.position[1], + u=self.position[2], + device=device, ) + joint_1_rotation = ( + utils.rotate_n( + n=self.deviation_parameters.first_joint_tilt_n, device=device + ) + @ utils.rotate_u( + u=self.deviation_parameters.first_joint_tilt_u, device=device + ) + @ utils.translate_enu( + e=self.deviation_parameters.first_joint_translation_e, + n=self.deviation_parameters.first_joint_translation_n, + u=self.deviation_parameters.first_joint_translation_u, + device=device, + ) + @ utils.rotate_e(joint_1_angle, device=device) + ) + joint_2_rotation = ( + utils.rotate_e( + e=self.deviation_parameters.second_joint_tilt_e, device=device + ) + @ utils.rotate_n( + n=self.deviation_parameters.second_joint_tilt_n, device=device + ) + @ utils.translate_enu( + e=self.deviation_parameters.second_joint_translation_e, + n=self.deviation_parameters.second_joint_translation_n, + u=self.deviation_parameters.second_joint_translation_u, + device=device, + ) + @ utils.rotate_u(joint_2_angle, device=device) + ) + + orientation = ( + initial_orientation + @ joint_1_rotation + @ joint_2_rotation + @ utils.translate_enu( + e=self.deviation_parameters.concentrator_translation_e, + n=self.deviation_parameters.concentrator_translation_n, + u=self.deviation_parameters.concentrator_translation_u, + device=device, + ) + ) + + east_angle, north_angle, up_angle = utils.decompose_rotation( + initial_vector=self.initial_orientation_helisotat[:-1], + target_vector=self.artist_standard_orientation[:-1], + device=device, + ) + + # Return orientation matrix multiplied by the initial orientation offset. + return ( + orientation + @ utils.rotate_e( + e=east_angle, + device=device, + ) + @ utils.rotate_n( + n=north_angle, + device=device, + ) + @ utils.rotate_u( + u=up_angle, + device=device, + ) + ) + + def align_surface_with_motor_positions( + self, + motor_positions: torch.Tensor, + surface_points: torch.Tensor, + surface_normals: torch.Tensor, + device: Union[torch.device, str] = "cuda", + ) -> tuple[torch.Tensor, torch.Tensor]: + """ + Align given surface points and surface normals according to motor positions. + + Parameters + ---------- + incident_ray_direction : torch.Tensor + The direction of the rays. + surface_points : torch.Tensor + Points on the surface of the heliostat that reflect the light. + surface_normals : torch.Tensor + Normals to the surface points. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + torch.Tensor + The aligned surface points. + torch.Tensor + The aligned surface normals. + """ + device = torch.device(device) + + orientation = self.motor_positions_to_orientation( + motor_positions, device=device + ) + + aligned_surface_points = surface_points @ orientation.T + aligned_surface_normals = surface_normals @ orientation.T + return aligned_surface_points, aligned_surface_normals + + def forward(self) -> None: + """ + Specify the forward pass. + + Raises + ------ + NotImplementedError + Whenever called. + """ + raise NotImplementedError("Not Implemented!") diff --git a/artist/field/receiver.py b/artist/field/receiver.py index 1cd64a4e2..9460bc0a8 100644 --- a/artist/field/receiver.py +++ b/artist/field/receiver.py @@ -36,6 +36,8 @@ class Receiver(torch.nn.Module): ------- from_hdf5() Class method that initializes a receiver from an HDF5 file. + forward() + Specify the forward pass. """ def __init__( @@ -158,3 +160,14 @@ def from_hdf5( curvature_e=curvature_e, curvature_u=curvature_u, ) + + def forward(self) -> None: + """ + Specify the forward pass. + + Raises + ------ + NotImplementedError + Whenever called. + """ + raise NotImplementedError("Not Implemented!") diff --git a/artist/field/receiver_field.py b/artist/field/receiver_field.py index 1fda479a7..d5d7e8056 100644 --- a/artist/field/receiver_field.py +++ b/artist/field/receiver_field.py @@ -25,6 +25,8 @@ class ReceiverField(torch.nn.Module): ------- from_hdf5() Load the list of receivers from an HDF5 file. + forward() + Specify the forward pass. """ def __init__(self, receiver_list: list[Receiver]): @@ -72,3 +74,14 @@ def from_hdf5( for receiver_name in config_file[config_dictionary.receiver_key].keys() ] return cls(receiver_list=receiver_field) + + def forward(self) -> None: + """ + Specify the forward pass. + + Raises + ------ + NotImplementedError + Whenever called. + """ + raise NotImplementedError("Not Implemented!") diff --git a/artist/field/surface.py b/artist/field/surface.py index e43204bac..48733aee3 100644 --- a/artist/field/surface.py +++ b/artist/field/surface.py @@ -19,6 +19,8 @@ class Surface(torch.nn.Module): ------- get_surface_points_and_normals() Calculate all surface points and normals from all facets. + forward() + Specify the forward pass. """ def __init__(self, surface_config: SurfaceConfig) -> None: @@ -43,8 +45,6 @@ def __init__(self, surface_config: SurfaceConfig) -> None: degree_n=facet_config.degree_n, number_eval_points_e=facet_config.number_eval_points_e, number_eval_points_n=facet_config.number_eval_points_n, - width=facet_config.width, - height=facet_config.height, translation_vector=facet_config.translation_vector, canting_e=facet_config.canting_e, canting_n=facet_config.canting_n, @@ -89,3 +89,14 @@ def get_surface_points_and_normals( surface_points[i] = facet_points + facet.translation_vector surface_normals[i] = facet_normals return surface_points, surface_normals + + def forward(self) -> None: + """ + Specify the forward pass. + + Raises + ------ + NotImplementedError + Whenever called. + """ + raise NotImplementedError("Not Implemented!") diff --git a/artist/raytracing/heliostat_tracing.py b/artist/raytracing/heliostat_tracing.py index c6e9faf09..c4abaa738 100644 --- a/artist/raytracing/heliostat_tracing.py +++ b/artist/raytracing/heliostat_tracing.py @@ -249,7 +249,7 @@ def trace_rays( total_bitmap = self.sample_bitmap(dx_ints, dy_ints, indices, device=device) - final_bitmap += total_bitmap + final_bitmap = final_bitmap + total_bitmap return final_bitmap diff --git a/artist/raytracing/rays.py b/artist/raytracing/rays.py index 225f278b8..5f14b8344 100644 --- a/artist/raytracing/rays.py +++ b/artist/raytracing/rays.py @@ -33,13 +33,14 @@ def __init__( Raises ------ - AssertionError + ValueError If the length of the ray directions does not match the length of the ray magnitudes. """ - assert ( - ray_directions.size(dim=0) == ray_magnitudes.size(dim=0) - and ray_directions.size(dim=1) == ray_magnitudes.size(dim=1) - and ray_directions.size(dim=2) == ray_magnitudes.size(dim=2) - ), "Ray directions and magnitudes have differing sizes!" + if ( + (ray_directions.size(dim=0) != ray_magnitudes.size(dim=0)) + or (ray_directions.size(dim=1) != ray_magnitudes.size(dim=1)) + or (ray_directions.size(dim=2) != ray_magnitudes.size(dim=2)) + ): + raise ValueError("Ray directions and magnitudes have differing sizes!") self.ray_directions = ray_directions self.ray_magnitudes = ray_magnitudes diff --git a/artist/raytracing/raytracing_utils.py b/artist/raytracing/raytracing_utils.py index 33eb4052f..068ac4965 100644 --- a/artist/raytracing/raytracing_utils.py +++ b/artist/raytracing/raytracing_utils.py @@ -60,9 +60,9 @@ def line_plane_intersections( # Use the cosine between the ray directions and the normals to calculate the relative distribution strength of # the incoming rays. relative_distribution_strengths = ray_directions @ plane_normal_vectors - assert ( - torch.abs(relative_distribution_strengths) >= epsilon - ).all(), "No intersection or line is within plane." + + if (torch.abs(relative_distribution_strengths) <= epsilon).all(): + raise ValueError("No intersection or line is within plane.") # Calculate the final distribution strengths. distribution_strengths = ( diff --git a/artist/scenario.py b/artist/scenario.py index 55258afa4..14c00cd13 100644 --- a/artist/scenario.py +++ b/artist/scenario.py @@ -8,7 +8,7 @@ from artist.field.heliostat_field import HeliostatField from artist.field.receiver_field import ReceiverField from artist.scene.light_source_array import LightSourceArray -from artist.util import config_dictionary, set_logger_config +from artist.util import config_dictionary from artist.util.configuration_classes import ( ActuatorConfig, ActuatorListConfig, @@ -16,11 +16,9 @@ FacetConfig, KinematicDeviations, KinematicLoadConfig, - KinematicOffsets, SurfaceConfig, ) -set_logger_config() log = logging.getLogger(__name__) """A logger for the scenario.""" @@ -31,6 +29,8 @@ class Scenario: Attributes ---------- + power_plant_position : torch.Tensor + The position of the power plant as latitude, longitude, altitude. receivers : ReceiverField A list of receivers included in the scenario. light_sources : LightSourceArray @@ -46,6 +46,7 @@ class Scenario: def __init__( self, + power_plant_position: torch.Tensor, receivers: ReceiverField, light_sources: LightSourceArray, heliostat_field: HeliostatField, @@ -59,6 +60,8 @@ def __init__( Parameters ---------- + power_plant_position : torch.Tensor, + The position of the power plant as latitude, longitude, altitude. receivers : ReceiverField A list of receivers included in the scenario. light_sources : LightSourceArray @@ -66,6 +69,7 @@ def __init__( heliostat_field : HeliostatField A field of heliostats included in the scenario. """ + self.power_plant_position = power_plant_position self.receivers = receivers self.light_sources = light_sources self.heliostats = heliostat_field @@ -93,6 +97,11 @@ def load_scenario_from_hdf5( f"Loading an ``ARTIST`` scenario HDF5 file. This scenario file is version {scenario_file.attrs['version']}." ) device = torch.device(device) + power_plant_position = torch.tensor( + scenario_file[config_dictionary.power_plant_key][ + config_dictionary.power_plant_position + ][()] + ) receivers = ReceiverField.from_hdf5(config_file=scenario_file, device=device) light_sources = LightSourceArray.from_hdf5( config_file=scenario_file, device=device @@ -138,20 +147,6 @@ def load_scenario_from_hdf5( config_dictionary.facet_number_eval_n ][()] ), - width=float( - scenario_file[config_dictionary.prototype_key][ - config_dictionary.surface_prototype_key - ][config_dictionary.facets_key][facet][ - config_dictionary.facets_width - ][()] - ), - height=float( - scenario_file[config_dictionary.prototype_key][ - config_dictionary.surface_prototype_key - ][config_dictionary.facets_key][facet][ - config_dictionary.facets_height - ][()] - ), translation_vector=torch.tensor( scenario_file[config_dictionary.prototype_key][ config_dictionary.surface_prototype_key @@ -187,61 +182,12 @@ def load_scenario_from_hdf5( surface_prototype = SurfaceConfig(facets_list=facets_list) # Create kinematic prototype. - kinematic_initial_orientation_offset_e = scenario_file.get( - f"{config_dictionary.prototype_key}/{config_dictionary.kinematic_prototype_key}/" - f"{config_dictionary.kinematic_offsets_key}/{config_dictionary.kinematic_initial_orientation_offset_e}" - ) - kinematic_initial_orientation_offset_n = scenario_file.get( - f"{config_dictionary.prototype_key}/{config_dictionary.kinematic_prototype_key}/" - f"{config_dictionary.kinematic_offsets_key}/{config_dictionary.kinematic_initial_orientation_offset_n}" - ) - kinematic_initial_orientation_offset_u = scenario_file.get( - f"{config_dictionary.prototype_key}/{config_dictionary.kinematic_prototype_key}/" - f"{config_dictionary.kinematic_offsets_key}/{config_dictionary.kinematic_initial_orientation_offset_u}" - ) - if kinematic_initial_orientation_offset_e is None: - log.warning( - f"No kinematic prototype {config_dictionary.kinematic_initial_orientation_offset_e} set." - f"Using default values!" - ) - if kinematic_initial_orientation_offset_n is None: - log.warning( - f"No kinematic prototype {config_dictionary.kinematic_initial_orientation_offset_n} set." - f"Using default values!" - ) - if kinematic_initial_orientation_offset_u is None: - log.warning( - f"No kinematic prototype {config_dictionary.kinematic_initial_orientation_offset_u} set." - f"Using default values!" - ) - kinematic_offsets = KinematicOffsets( - kinematic_initial_orientation_offset_e=( - torch.tensor( - kinematic_initial_orientation_offset_e[()], - dtype=torch.float, - device=device, - ) - if kinematic_initial_orientation_offset_e - else torch.tensor(0.0, dtype=torch.float, device=device) - ), - kinematic_initial_orientation_offset_n=( - torch.tensor( - kinematic_initial_orientation_offset_n[()], - dtype=torch.float, - device=device, - ) - if kinematic_initial_orientation_offset_n - else torch.tensor(0.0, dtype=torch.float, device=device) - ), - kinematic_initial_orientation_offset_u=( - torch.tensor( - kinematic_initial_orientation_offset_u[()], - dtype=torch.float, - device=device, - ) - if kinematic_initial_orientation_offset_u - else torch.tensor(0.0, dtype=torch.float, device=device) - ), + initial_orientation = torch.tensor( + scenario_file[config_dictionary.prototype_key][ + config_dictionary.kinematic_prototype_key + ][config_dictionary.kinematic_initial_orientation][()], + dtype=torch.float, + device=device, ) first_joint_translation_e = scenario_file.get( @@ -535,13 +481,13 @@ def load_scenario_from_hdf5( ), ) kinematic_prototype = KinematicLoadConfig( - kinematic_type=str( + type=str( scenario_file[config_dictionary.prototype_key][ config_dictionary.kinematic_prototype_key ][config_dictionary.kinematic_type][()].decode("utf-8") ), - kinematic_initial_orientation_offsets=kinematic_offsets, - kinematic_deviations=kinematic_deviations, + initial_orientation=initial_orientation, + deviations=kinematic_deviations, ) # Create actuator prototype. @@ -567,17 +513,17 @@ def load_scenario_from_hdf5( f"{config_dictionary.actuator_parameters_key}/" f"{config_dictionary.actuator_offset}" ) - radius = scenario_file.get( + pivot_radius = scenario_file.get( f"{config_dictionary.prototype_key}/" f"{config_dictionary.actuator_prototype_key}/{ac}/" f"{config_dictionary.actuator_parameters_key}/" - f"{config_dictionary.actuator_radius}" + f"{config_dictionary.actuator_pivot_radius}" ) - phi_0 = scenario_file.get( + initial_angle = scenario_file.get( f"{config_dictionary.prototype_key}/" f"{config_dictionary.actuator_prototype_key}/{ac}/" f"{config_dictionary.actuator_parameters_key}/" - f"{config_dictionary.actuator_phi_0}" + f"{config_dictionary.actuator_initial_angle}" ) if increment is None: log.warning( @@ -592,13 +538,13 @@ def load_scenario_from_hdf5( log.warning( f"No prototype {config_dictionary.actuator_offset} set for {ac}. Using default values!" ) - if radius is None: + if pivot_radius is None: log.warning( - f"No prototype {config_dictionary.actuator_radius} set for {ac}. Using default values!" + f"No prototype {config_dictionary.actuator_pivot_radius} set for {ac}. Using default values!" ) - if phi_0 is None: + if initial_angle is None: log.warning( - f"No prototype {config_dictionary.actuator_phi_0} set for {ac}. Using default values!" + f"No prototype {config_dictionary.actuator_initial_angle} set for {ac}. Using default values!" ) actuator_parameters = ActuatorParameters( @@ -619,31 +565,31 @@ def load_scenario_from_hdf5( if offset else torch.tensor(0.0, dtype=torch.float, device=device) ), - radius=( - torch.tensor(radius[()], dtype=torch.float, device=device) - if radius + pivot_radius=( + torch.tensor(pivot_radius[()], dtype=torch.float, device=device) + if pivot_radius else torch.tensor(0.0, dtype=torch.float, device=device) ), - phi_0=( - torch.tensor(phi_0[()], dtype=torch.float, device=device) - if phi_0 + initial_angle=( + torch.tensor(initial_angle[()], dtype=torch.float, device=device) + if initial_angle else torch.tensor(0.0, dtype=torch.float, device=device) ), ) actuator_list.append( ActuatorConfig( - actuator_key="", - actuator_type=str( + key="", + type=str( scenario_file[config_dictionary.prototype_key][ config_dictionary.actuator_prototype_key ][ac][config_dictionary.actuator_type_key][()].decode("utf-8") ), - actuator_clockwise=bool( + clockwise_axis_movement=bool( scenario_file[config_dictionary.prototype_key][ config_dictionary.actuator_prototype_key - ][ac][config_dictionary.actuator_clockwise][()] + ][ac][config_dictionary.actuator_clockwise_axis_movement][()] ), - actuator_parameters=actuator_parameters, + parameters=actuator_parameters, ) ) actuator_prototype = ActuatorListConfig(actuator_list=actuator_list) @@ -657,6 +603,7 @@ def load_scenario_from_hdf5( ) return cls( + power_plant_position=power_plant_position, receivers=receivers, light_sources=light_sources, heliostat_field=heliostat_field, diff --git a/artist/scene/light_source.py b/artist/scene/light_source.py index 81087701d..0705a4f3f 100644 --- a/artist/scene/light_source.py +++ b/artist/scene/light_source.py @@ -18,6 +18,8 @@ class LightSource(torch.nn.Module): ------- get_distortions() Get distortions given the light source model. + forward() + Specify the forward pass. """ def __init__( @@ -92,3 +94,14 @@ def get_distortions( Whenever called (abstract base class method). """ raise NotImplementedError("Must be overridden!") + + def forward(self) -> None: + """ + Specify the forward pass. + + Raises + ------ + NotImplementedError + Whenever called. + """ + raise NotImplementedError("Must be overridden!") diff --git a/artist/scene/light_source_array.py b/artist/scene/light_source_array.py index 8ae0ce32d..eefc57053 100644 --- a/artist/scene/light_source_array.py +++ b/artist/scene/light_source_array.py @@ -29,6 +29,8 @@ class LightSourceArray(torch.nn.Module): ------- from_hdf5() Load the list of light sources from an HDF5 file. + forward() + Specify the forward pass. """ def __init__(self, light_source_list: list[LightSource]): @@ -90,3 +92,14 @@ def from_hdf5( f"Currently the selected light source: {mapping_key} is not supported." ) return cls(light_source_list=light_source_array) + + def forward(self) -> None: + """ + Specify the forward pass. + + Raises + ------ + NotImplementedError + Whenever called. + """ + raise NotImplementedError("Not Implemented!") diff --git a/artist/scene/sun.py b/artist/scene/sun.py index 648085c23..a1f154384 100644 --- a/artist/scene/sun.py +++ b/artist/scene/sun.py @@ -29,6 +29,8 @@ class Sun(LightSource): Class method to initialize a sun from an HDF5 file. get_distortions() Returns distortions used to rotate rays. + forward() + Specify the forward pass. See Also -------- @@ -62,7 +64,7 @@ def __init__( Raises ------ - ValueError | NotImplementedError + ValueError If the specified distribution type is unknown. """ super().__init__(number_of_rays=number_of_rays) @@ -70,13 +72,13 @@ def __init__( self.distribution_parameters = distribution_parameters self.number_of_rays = number_of_rays - - assert ( + if ( self.distribution_parameters[ config_dictionary.light_source_distribution_type ] - == config_dictionary.light_source_distribution_is_normal - ), "Unknown sunlight distribution type." + != config_dictionary.light_source_distribution_is_normal + ): + raise ValueError("Unknown sunlight distribution type.") if ( self.distribution_parameters[ @@ -217,26 +219,24 @@ def get_distortions( ------- tuple[torch.Tensor, torch.Tensor] The distortion in north and up direction. + """ + torch.manual_seed(random_seed) + distortions_u, distortions_e = self.distribution.sample( + ( + int(number_of_heliostats * self.number_of_rays), + number_of_facets, + number_of_points, + ), + ).permute(3, 0, 1, 2) + return distortions_u, distortions_e + + def forward(self) -> None: + """ + Specify the forward pass. Raises ------ - ValueError - If the distribution type is not valid, currently only the normal distribution is implemented. + NotImplementedError + Whenever called. """ - torch.manual_seed(random_seed) - if ( - self.distribution_parameters[ - config_dictionary.light_source_distribution_type - ] - == config_dictionary.light_source_distribution_is_normal - ): - distortions_u, distortions_e = self.distribution.sample( - ( - int(number_of_heliostats * self.number_of_rays), - number_of_facets, - number_of_points, - ), - ).permute(3, 0, 1, 2) - return distortions_u, distortions_e - else: - raise ValueError("Unknown light distribution type.") + raise NotImplementedError("Not Implemented!") diff --git a/artist/util/alignment_optimizer.py b/artist/util/alignment_optimizer.py new file mode 100644 index 000000000..8e8772ab2 --- /dev/null +++ b/artist/util/alignment_optimizer.py @@ -0,0 +1,309 @@ +import logging +from typing import Optional, Union + +import torch +from torch.optim import Optimizer +from torch.optim.lr_scheduler import ReduceLROnPlateau, _LRScheduler + +from artist.raytracing import raytracing_utils +from artist.raytracing.heliostat_tracing import HeliostatRayTracer +from artist.scenario import Scenario +from artist.util import utils + +log = logging.getLogger(__name__) +"""A logger for the alignment optimizer.""" + + +class AlignmentOptimizer: + """ + Alignment optimizer to find optimal kinematic parameters. + + The alignment optimizer optimizes parameters of the rigid body kinematics. + These parameters can include the 18 kinematic deviations parameters as well as five actuator + parameters for each actuator. + + Attributes + ---------- + scenario : Scenario + The scenario. + optimizer : Optimizer + The optimizer. + scheduler : Union[_LRScheduler, ReduceLROnPlateau] + The learning rate scheduler. + + Methods + ------- + optimize() + Optimize the kinematic parameters. + """ + + def __init__( + self, + scenario: Scenario, + optimizer: Optimizer, + scheduler: Union[_LRScheduler, ReduceLROnPlateau], + ) -> None: + """ + Initialize the alignment optimizer. + + Parameters + ---------- + scenario : Scenario + The scenario. + optimizer : Optimizer + The optimizer. + scheduler : Union[_LRScheduler, ReduceLROnPlateau] + The learning rate scheduler. + """ + log.info("Create alignment optimizer.") + self.scenario = scenario + self.optimizer = optimizer + self.scheduler = scheduler + + def optimize( + self, + tolerance: float, + max_epoch: int, + center_calibration_image: torch.Tensor, + incident_ray_direction: torch.Tensor, + motor_positions: Optional[torch.Tensor] = None, + num_log: int = 3, + device: Union[torch.device, str] = "cuda", + ) -> tuple[list[torch.Tensor], Scenario]: + """ + Optimize the kinematic parameters. + + Parameters + ---------- + tolerance : float + The optimizer tolerance. + max_epoch : int + The maximum number of optimization epochs. + center_calibration_image : torch.Tensor + The center of the calibration flux density. + incident_ray_direction : torch.Tensor + The incident ray direction specified in the calibration. + motor_positions : Optional[torch.Tensor] + The motor positions specified in the calibration (default is ``None``). + num_log : int + Number of log messages during training (default is 3). + device : Union[torch.device, str] = "cuda" + The device on which to initialize tensors (default is cuda). + + Returns + ------- + list[torch.Tensor] + The list of optimized kinematic parameters. + Scenario + The scenario with aligned heliostat and optimized kinematic parameters. + """ + log.info("Start alignment optimization.") + device = torch.device(device) + + if motor_positions is not None: + optimized_parameters, self.scenario = ( + self._optimize_kinematic_parameters_with_motor_positions( + tolerance=tolerance, + max_epoch=max_epoch, + center_calibration_image=center_calibration_image, + incident_ray_direction=incident_ray_direction, + motor_positions=motor_positions, + num_log=num_log, + device=device, + ) + ) + else: + optimized_parameters, self.scenario = ( + self._optimize_kinematic_parameters_with_raytracing( + tolerance=tolerance, + max_epoch=max_epoch, + center_calibration_image=center_calibration_image, + incident_ray_direction=incident_ray_direction, + num_log=num_log, + device=device, + ) + ) + log.info("Alignment optimized.") + return optimized_parameters, self.scenario + + def _optimize_kinematic_parameters_with_motor_positions( + self, + tolerance: float, + max_epoch: int, + center_calibration_image: torch.Tensor, + incident_ray_direction: torch.Tensor, + motor_positions: torch.Tensor, + num_log: int = 3, + device: Union[torch.device, str] = "cuda", + ) -> tuple[list[torch.Tensor], Scenario]: + """ + Optimize the kinematic parameters using the motor positions. + + This optimizer method optimizes the kinematic parameters by extracting the motor positions + and incident ray direction from a specific calibration and using the scene's geometry. + + Parameters + ---------- + tolerance : float + The optimzer tolerance. + max_epoch : int + The maximum number of optimization epochs. + center_calibration_image : torch.Tensor + The center of the calibration flux density. + incident_ray_direction : torch.Tensor + The incident ray direction specified in the calibration. + motor_positions : torch.Tensor + The motor positions specified in the calibration. + num_log : int + Number of log messages during training (default is 3). + device : Union[torch.device, str] + The device on which to initialize tensors (default: cuda). + + Returns + ------- + list[torch.Tensor] + The list of optimized parameters. + Scenario + The scenario with aligned heliostat and optimized kinematic parameters. + """ + log.info("Alignment optimization with motor positions.") + device = torch.device(device) + loss = torch.inf + epoch = 0 + + preferred_reflection_direction_calibration = ( + center_calibration_image + - self.scenario.heliostats.heliostat_list[0].position + ) + preferred_reflection_direction_calibration = ( + preferred_reflection_direction_calibration + / torch.norm(preferred_reflection_direction_calibration) + ) + + log_step = max_epoch // num_log + while loss > tolerance and epoch <= max_epoch: + orientation = self.scenario.heliostats.heliostat_list[ + 0 + ].get_orientation_from_motor_positions( + motor_positions=motor_positions, device=device + ) + + preferred_reflection_direction = raytracing_utils.reflect( + -incident_ray_direction, orientation[0:4, 2] + ) + + self.optimizer.zero_grad() + + loss = ( + ( + preferred_reflection_direction + - preferred_reflection_direction_calibration + ) + .abs() + .mean() + ) + loss.backward() + + self.optimizer.step() + self.scheduler.step(loss) + + if epoch % log_step == 0: + log.info( + f"Epoch: {epoch}, Loss: {loss.item()}, LR: {self.optimizer.param_groups[0]['lr']}", + ) + + epoch += 1 + + self.scenario.heliostats.heliostat_list[ + 0 + ].set_aligned_surface_with_motor_positions( + motor_positions=motor_positions.to(device), device=device + ) + + return self.optimizer.param_groups[0]["params"], self.scenario + + def _optimize_kinematic_parameters_with_raytracing( + self, + tolerance: float, + max_epoch: int, + center_calibration_image: torch.Tensor, + incident_ray_direction: torch.Tensor, + num_log: int = 3, + device: Union[torch.device, str] = "cuda", + ) -> tuple[list[torch.Tensor], Scenario]: + """ + Optimize the kinematic parameters using raytracing. + + This optimizer method optimizes the kinematic parameters by extracting the focus point + of a calibration image and using heliostat-tracing. This method is slower than the other + optimization method found in the alignment optimizer. + + Parameters + ---------- + tolerance : float + The optimzer tolerance. + max_epoch : int + The maximum number of optimization epochs. + center_calibration_image : torch.Tensor + The center of the calibration flux density. + incident_ray_direction : torch.Tensor + The incident ray direction specified in the calibration. + num_log : int + Number of log messages during training (default is 3). + device : Union[torch.device, str] + The device on which to initialize tensors (default: cuda). + + Returns + ------- + list[torch.Tensor] + The list of optimized parameters. + Scenario + The scenario with aligned heliostat and optimized kinematic parameters. + """ + log.info("Alignment optimization with raytracing.") + device = torch.device(device) + loss = torch.inf + epoch = 0 + + log_step = max_epoch // num_log + while loss > tolerance and epoch <= max_epoch: + # Align heliostat. + self.scenario.heliostats.heliostat_list[ + 0 + ].set_aligned_surface_with_incident_ray_direction( + incident_ray_direction=incident_ray_direction, device=device + ) + + # Create raytracer. + raytracer = HeliostatRayTracer(scenario=self.scenario) + + final_bitmap = raytracer.trace_rays( + incident_ray_direction=incident_ray_direction, device=device + ) + + final_bitmap = raytracer.normalize_bitmap(final_bitmap) + + center = utils.get_center_of_mass( + bitmap=torch.flip(final_bitmap, dims=(0, 1)), + target_center=self.scenario.receivers.receiver_list[0].position_center, + plane_e=self.scenario.receivers.receiver_list[0].plane_e, + plane_u=self.scenario.receivers.receiver_list[0].plane_u, + device=device, + ) + + self.optimizer.zero_grad() + + loss = (center - center_calibration_image).abs().mean() + loss.backward() + + self.optimizer.step() + self.scheduler.step(loss) + + if epoch % log_step == 0: + log.info( + f"Epoch: {epoch}, Loss: {loss.item()}, LR: {self.optimizer.param_groups[0]['lr']}", + ) + + epoch += 1 + + return self.optimizer.param_groups[0]["params"], self.scenario diff --git a/artist/util/config_dictionary.py b/artist/util/config_dictionary.py index 6484b9b01..c24b32386 100644 --- a/artist/util/config_dictionary.py +++ b/artist/util/config_dictionary.py @@ -1,3 +1,6 @@ +power_plant_position = "position" +"""Key to access the power plant position.""" + receiver_type = "type" """Key to access the receiver type.""" receiver_position_center = "position_center" @@ -50,10 +53,6 @@ """Key to access the facet number of evaluation points in the east direction.""" facet_number_eval_n = "number_eval_n" """Key to access the facet number of evaluation points in the north direction.""" -facets_width = "width" -"""Key to access the facet width.""" -facets_height = "height" -"""Key to access the facet height.""" facets_translation_vector = "position" """Key to access the translation vector indicating the position of the facet relative to the center of the heliostat.""" facets_canting_e = "canting_e" @@ -98,42 +97,37 @@ concentrator_tilt_u = "concentrator_tilt_u" """Key to access the kinematic concentrator tilt in the up direction.""" -kinematic_initial_orientation_offset_e = "initial_kinematic_orientation_offset_e" -"""Key to access the initial kinematic orientation offset in the east direction.""" -kinematic_initial_orientation_offset_n = "initial_kinematic_orientation_offset_n" -"""Key to access the initial kinematic orientation offset in the north direction.""" -kinematic_initial_orientation_offset_u = "initial_kinematic_orientation_offset_u" -"""Key to access the initial kinematic orientation offset in the up direction.""" - +kinematic_initial_orientation = "initial_orientation" +"""Key to access the initial orientation.""" kinematic_type = "type" """Key to access the kinematic type.""" rigid_body_key = "rigid_body" """Key to define that the rigid body kinematic is used as a kinematic type.""" -kinematic_offsets_key = "kinematic_offsets" +kinematic_offsets_key = "offsets" """Key to access the kinematic offsets.""" -kinematic_deviations_key = "kinematic_deviations" +kinematic_deviations_key = "deviations" """Key to access the kinematic deviations.""" -actuator_clockwise = "clockwise" +actuator_clockwise_axis_movement = "clockwise_axis_movement" """Key to access the actuator clockwise parameter.""" -actuator_increment = "actuator_increment" +actuator_increment = "increment" """Key to access the actuator increment parameter.""" actuator_initial_stroke_length = "initial_stroke_length" """Key to access the actuator initial stroke length parameter.""" actuator_offset = "offset" """Key to access the actuator offset parameter.""" -actuator_radius = "radius" -"""Key to access the actuator radius parameter.""" -actuator_phi_0 = "phi_0" -"""Key to access the actuator phi 0 parameter.""" +actuator_pivot_radius = "pivot_radius" +"""Key to access the actuator pivot radius parameter.""" +actuator_initial_angle = "initial_angle" +"""Key to access the actuator initial angle parameter.""" -actuator_type_key = "actuator_type" +actuator_type_key = "type" """Key to access the actuator type.""" -actuator_parameters_key = "actuator_parameters" +actuator_parameters_key = "parameters" """Key to access the actuator parameters.""" -ideal_actuator_key = "ideal_actuator" +ideal_actuator_key = "ideal" """Key defining that the actuator used is an ideal actuator.""" -linear_actuator_key = "linear_actuator" +linear_actuator_key = "linear" """Key defining that the actuator used is a linear actuator.""" surface_prototype_key = "surface" @@ -158,6 +152,8 @@ prototype_key = "prototypes" """Key to access the prototypes.""" +power_plant_key = "power_plant" +"""Key to access the power plant.""" receiver_key = "receivers" """Key to access the receivers.""" light_source_key = "lightsources" @@ -169,3 +165,101 @@ """Defines that the NURBS should be learnt using a point cloud.""" convert_nurbs_from_normals = "deflectometry" """Defines that the NURBS should be learnt using deflectometry data.""" + +optimizer_use_raytracing = "use_raytracing" +"""Defines that the alignment optimizer uses raytracing.""" +optimizer_use_motor_positions = "use_motor_positions" +"""Defines that the alignment optimizer uses motor positions.""" + + +paint_receiver_type = "type" +"""Key to access the receiver type in a ``PAINT`` tower.json file.""" +paint_calibration_traget = "target_name" +"""Key to access the calibration target name in a ``PAINT`` tower.json file.""" +paint_power_plant_properties = "power_plant_properties" +"""Key to access the power plant properties in a ``PAINT`` tower.json file.""" +paint_normal_vector = "normal_vector" +"""Key to access normal vectors of a plane in a ``PAINT`` tower.json file.""" +paint_coordinates = "coordinates" +"""Key to access coordinates in a ``PAINT`` tower.json file.""" +paint_center = "center" +"""Key to access center coordinates in a ``PAINT`` tower.json file.""" +paint_upper_left = "upper_left" +"""Key to access upper left coordinates in a ``PAINT`` tower.json file.""" +paint_lower_left = "lower_left" +"""Key to access lower left coordinates in a ``PAINT`` tower.json file.""" +paint_upper_right = "upper_right" +"""Key to access upper right coordinates in a ``PAINT`` tower.json file.""" +paint_lower_right = "lower_right" +"""Key to access lower right coordinates in a ``PAINT`` tower.json file.""" +paint_heliostat_position = "heliostat_position" +"""Key to access the heliostat position in a ``PAINT`` heliostat_properties.json file.""" +paint_kinematic = "kinematic_properties" +"""Key to access the kinematic properties in a ``PAINT`` heliostat_properties.json file.""" +paint_first_joint_translation_e = "joint_translation_e_1" +"""Key to access the kinematic first joint translation in the east direction in a ``PAINT`` heliostat_properties.json file.""" +paint_first_joint_translation_n = "joint_translation_n_1" +"""Key to access the kinematic first joint translation in the north direction in a ``PAINT`` heliostat_properties.json file.""" +paint_first_joint_translation_u = "joint_translation_u_1" +"""Key to access the kinematic first joint translation in the up direction in a ``PAINT`` heliostat_properties.json file.""" +paint_second_joint_translation_e = "joint_translation_e_2" +"""Key to access the kinematic second joint translation in the east direction in a ``PAINT`` heliostat_properties.json file.""" +paint_second_joint_translation_n = "joint_translation_n_2" +"""Key to access the kinematic second joint translation in the north direction in a ``PAINT`` heliostat_properties.json file.""" +paint_second_joint_translation_u = "joint_translation_u_2" +"""Key to access the kinematic second joint translation in the up direction in a ``PAINT`` heliostat_properties.json file.""" +paint_concentrator_translation_e = "concentrator_translation_e" +"""Key to access the kinematic concentrator translation in the east direction in a ``PAINT`` heliostat_properties.json file.""" +paint_concentrator_translation_n = "concentrator_translation_u" +"""Key to access the kinematic concentrator translation in the north direction in a ``PAINT`` heliostat_properties.json file.""" +paint_concentrator_translation_u = "concentrator_translation_n" +"""Key to access the kinematic concentrator translation in the up direction in a ``PAINT`` heliostat_properties.json file.""" +paint_initial_orientation = "initial_orientation" +"""Key to access the initial orientation in a ``PAINT`` heliostat_properties.json file.""" +paint_actuators = "actuators" +"""Key to access the actuators in a ``PAINT`` heliostat_properties.json file.""" +paint_increment = "increment" +"""Key to access the actuators increment in a ``PAINT`` heliostat_properties.json file.""" +paint_initial_stroke_length = "initial_stroke_length" +"""Key to access the actuators initial stroke length in a ``PAINT`` heliostat_properties.json file.""" +paint_offset = "offset" +"""Key to access the actuators offset in a ``PAINT`` heliostat_properties.json file.""" +paint_pivot_radius = "pivot_radius" +"""Key to access the actuators pivot radius in a ``PAINT`` heliostat_properties.json file.""" +paint_initial_angle = "initial_angle" +"""Key to access the actuators initial angle in a ``PAINT`` heliostat_properties.json file.""" +paint_actuator_type = "type_axis" +"""Key to access the actuator type in a ``PAINT`` heliostat_properties.json file.""" +paint_clockwise_axis_movement = "clockwise_axis_movement" +"""Key to access the actuators axis movement direction in a ``PAINT`` heliostat_properties.json file.""" +paint_facet_properties = "facet_properties" +"""Key to access the facet properties in a ``PAINT`` heliostat_properties.json file.""" +paint_number_of_facets = "number_of_facets" +"""Key to access the number of facets in a ``PAINT`` heliostat_properties.json file.""" +paint_facets = "facets" +"""Key to access the facets in a ``PAINT`` heliostat_properties.json file.""" +paint_translation_vetor = "translation_vector" +"""Key to access the facet translation vector in a ``PAINT`` heliostat_properties.json file..""" +paint_canting_e = "canting_e" +"""Key to access the facet canting vector in east dimension in a ``PAINT`` heliostat_properties.json file.""" +paint_canting_n = "canting_n" +"""Key to access the facet canting vector in north dimension in a ``PAINT`` heliostat_properties.json file.""" +paint_facet = "facet" +"""Key to access a single facet in a ``PAINT`` deflectometry.h5 file.""" +paint_surface_points = "surface_points" +"""Key to access the surface points in a ``PAINT`` deflectometry.h5 file.""" +paint_surface_normals = "surface_normals" +"""Key to access the surface normals in a ``PAINT`` deflectometry.h5 file.""" +paint_focal_spot = "focal_spot" +"""Key to access the focal spot of a calibration image in a ``PAINT`` calibration.json file.""" +paint_utis = "UTIS" +"""Key to access the UTIS focal spot in a ``PAINT`` calibration.json file.""" +paint_sun_azimuth = "sun_azimuth" +"""Key to access the sun azimuth in a ``PAINT`` calibration.json file.""" +paint_sun_elevation = "sun_elevation" +"""Key to acces the sun elevation in a ``PAINT`` calibration.json file.""" +paint_motor_positions = "motor_position" +"""Key to access motor positions in a ``PAINT`` calibration.json file.""" +paint_first_axis = "axis_1_motor_position" +"""Key to access the first motor position in a ``PAINT`` calibration.json file.""" +paint_second_axis = "axis_2_motor_position" diff --git a/artist/util/configuration_classes.py b/artist/util/configuration_classes.py index 7028fd449..2db548532 100644 --- a/artist/util/configuration_classes.py +++ b/artist/util/configuration_classes.py @@ -5,6 +5,50 @@ from artist.util import config_dictionary +class PowerPlantConfig: + """ + Store the power plant configuration parameters. + + Attributes + ---------- + power_plant_position : torch.Tensor + The position of the power plant as latitude, longitude, altitude. + + Methods + ------- + create_power_plant_dict() + Create a dictionary containing the configuration parameters for the power plant. + """ + + def __init__( + self, + power_plant_position: torch.Tensor, + ) -> None: + """ + Initialize the power plant configuration. + + Parameters + ---------- + power_plant_position : torch.Tensor + The position of the power plant as latitude, longitude, altitude. + """ + self.power_plant_position = power_plant_position + + def create_power_plant_dict(self) -> dict[str, Any]: + """ + Create a dictionary containing the configuration parameters for the power plant. + + Returns + ------- + dict[str, Any] + A dictionary containing the configuration parameters for the power plant. + """ + power_plant_dict = { + config_dictionary.power_plant_position: self.power_plant_position, + } + return power_plant_dict + + class ReceiverConfig: """ Store the receiver configuration parameters. @@ -210,15 +254,22 @@ def __init__( The mean used for modeling the sun. covariance : float, optional The covariance used for modeling the sun. + + Raises + ------ + ValueError + If the specified light source distribution type is unknown. """ self.light_source_key = light_source_key self.light_source_type = light_source_type self.number_of_rays = number_of_rays self.distribution_type = distribution_type - assert ( + + if ( self.distribution_type - == config_dictionary.light_source_distribution_is_normal - ), "Unknown light source distribution type." + != config_dictionary.light_source_distribution_is_normal + ): + raise ValueError("Unknown light source distribution type.") if ( self.distribution_type @@ -318,10 +369,6 @@ class FacetConfig: The number of evaluation points for the NURBS surface in the east direction. number_eval_points_n : int The number of evaluation points for the NURBS surface in the north direction. - width : float - The width of the facet. - height : float - The height of the facet. translation_vector : torch.Tensor The translation_vector of the facet. canting_e : torch.Tensor @@ -343,8 +390,6 @@ def __init__( degree_n: int, number_eval_points_e: int, number_eval_points_n: int, - width: float, - height: float, translation_vector: torch.Tensor, canting_e: torch.Tensor, canting_n: torch.Tensor, @@ -366,10 +411,6 @@ def __init__( The number of evaluation points for the NURBS surface in the east direction. number_eval_points_n : int The number of evaluation points for the NURBS surface in the north direction. - width : float - The width of the facet. - height : float - The height of the facet. translation_vector : torch.Tensor The translation_vector of the facet. canting_e : torch.Tensor @@ -383,8 +424,6 @@ def __init__( self.degree_n = degree_n self.number_eval_points_e = number_eval_points_e self.number_eval_points_n = number_eval_points_n - self.width = width - self.height = height self.translation_vector = translation_vector self.canting_e = canting_e self.canting_n = canting_n @@ -404,8 +443,6 @@ def create_facet_dict(self) -> dict[str, Any]: config_dictionary.facet_degree_n: self.degree_n, config_dictionary.facet_number_eval_e: self.number_eval_points_e, config_dictionary.facet_number_eval_n: self.number_eval_points_n, - config_dictionary.facets_width: self.width, - config_dictionary.facets_height: self.height, config_dictionary.facets_translation_vector: self.translation_vector, config_dictionary.facets_canting_e: self.canting_e, config_dictionary.facets_canting_n: self.canting_n, @@ -708,95 +745,17 @@ def create_kinematic_deviations_dict(self) -> dict[str, Any]: return deviations_dict -class KinematicOffsets: - """ - Store the kinematic offsets. - - Attributes - ---------- - kinematic_initial_orientation_offset_e : torch.Tensor, optional - The initial orientation offset in the east direction. - kinematic_initial_orientation_offset_n : torch.Tensor, optional - The initial orientation offset in the north direction. - kinematic_initial_orientation_offset_u : torch.Tensor, optional - The initial orientation offset in the up direction. - - Methods - ------- - create_kinematic_offsets_dict() - Create a dictionary containing the configuration parameters for the kinematic offsets. - """ - - def __init__( - self, - kinematic_initial_orientation_offset_e: Optional[torch.Tensor] = None, - kinematic_initial_orientation_offset_n: Optional[torch.Tensor] = None, - kinematic_initial_orientation_offset_u: Optional[torch.Tensor] = None, - ) -> None: - """ - Initialize the initial orientation offsets. - - Parameters - ---------- - kinematic_initial_orientation_offset_e : torch.Tensor, optional - The initial orientation offset in the east direction. - kinematic_initial_orientation_offset_n : torch.Tensor, optional - The initial orientation offset in the north direction. - kinematic_initial_orientation_offset_u : torch.Tensor, optional - The initial orientation offset in the up direction. - """ - self.kinematic_initial_orientation_offset_e = ( - kinematic_initial_orientation_offset_e - ) - self.kinematic_initial_orientation_offset_n = ( - kinematic_initial_orientation_offset_n - ) - self.kinematic_initial_orientation_offset_u = ( - kinematic_initial_orientation_offset_u - ) - - def create_kinematic_offsets_dict(self) -> dict[str, Any]: - """ - Create a dictionary containing the configuration parameters for the kinematic offsets. - - Returns - ------- - dict[str, Any] - A dictionary containing the configuration parameters for the kinematic offsets. - """ - offset_dict = {} - if self.kinematic_initial_orientation_offset_e is not None: - offset_dict.update( - { - config_dictionary.kinematic_initial_orientation_offset_e: self.kinematic_initial_orientation_offset_e - } - ) - if self.kinematic_initial_orientation_offset_n is not None: - offset_dict.update( - { - config_dictionary.kinematic_initial_orientation_offset_n: self.kinematic_initial_orientation_offset_n - } - ) - if self.kinematic_initial_orientation_offset_u is not None: - offset_dict.update( - { - config_dictionary.kinematic_initial_orientation_offset_u: self.kinematic_initial_orientation_offset_u - } - ) - return offset_dict - - class KinematicConfig: """ Store the configuration parameters for the kinematic. Attributes ---------- - kinematic_type : str + type : str The type of kinematic used. - kinematic_initial_orientation_offsets : KinematicOffsets, optional - The initial orientation offsets of the kinematic configuration. - kinematic_deviations : KinematicDeviations, optional + initial_orientation : torch.Tensor + The initial orientation of the kinematic configuration. + deviations : KinematicDeviations, optional The kinematic deviations. Methods @@ -807,27 +766,25 @@ class KinematicConfig: def __init__( self, - kinematic_type: str, - kinematic_initial_orientation_offsets: Optional[KinematicOffsets] = None, - kinematic_deviations: Optional[KinematicDeviations] = None, + type: str, + initial_orientation: torch.Tensor, + deviations: Optional[KinematicDeviations] = None, ) -> None: """ Initialize the kinematic configuration. Parameters ---------- - kinematic_type : str + type : str The type of kinematic used. - kinematic_initial_orientation_offsets : KinematicOffsets, optional - The initial orientation offsets of the kinematic configuration. - kinematic_deviations : KinematicDeviations, optional + initial_orientation : torch.Tensor + The initial orientation of the kinematic configuration. + deviations : KinematicDeviations, optional The kinematic deviations. """ - self.kinematic_type = kinematic_type - self.kinematic_initial_orientation_offsets = ( - kinematic_initial_orientation_offsets - ) - self.kinematic_deviations = kinematic_deviations + self.type = type + self.initial_orientation = initial_orientation + self.deviations = deviations def create_kinematic_dict(self) -> dict[str, Any]: """ @@ -839,18 +796,13 @@ def create_kinematic_dict(self) -> dict[str, Any]: A dictionary containing the configuration parameters for the kinematic. """ kinematic_dict: dict[str, Any] = { - config_dictionary.kinematic_type: self.kinematic_type + config_dictionary.kinematic_type: self.type, + config_dictionary.kinematic_initial_orientation: self.initial_orientation, } - if self.kinematic_initial_orientation_offsets is not None: + if self.deviations is not None: kinematic_dict.update( { - config_dictionary.kinematic_offsets_key: self.kinematic_initial_orientation_offsets.create_kinematic_offsets_dict() - } - ) - if self.kinematic_deviations is not None: - kinematic_dict.update( - { - config_dictionary.kinematic_deviations_key: self.kinematic_deviations.create_kinematic_deviations_dict() + config_dictionary.kinematic_deviations_key: self.deviations.create_kinematic_deviations_dict() } ) return kinematic_dict @@ -867,26 +819,26 @@ class KinematicPrototypeConfig(KinematicConfig): def __init__( self, - kinematic_type: str, - kinematic_initial_orientation_offsets: Optional[KinematicOffsets] = None, - kinematic_deviations: Optional[KinematicDeviations] = None, + type: str, + initial_orientation: torch.Tensor, + deviations: Optional[KinematicDeviations] = None, ) -> None: """ Initialize the kinematic prototype configuration. Parameters ---------- - kinematic_type : str + type : str The type of kinematic used. - kinematic_initial_orientation_offsets : KinematicOffsets, optional - The initial orientation offsets of the kinematic configuration. - kinematic_deviations : KinematicDeviations, optional + initial_orientation : torch.Tensor + The initial orientation of the kinematic configuration. + deviations : KinematicDeviations, optional The kinematic deviations. """ super().__init__( - kinematic_type=kinematic_type, - kinematic_initial_orientation_offsets=kinematic_initial_orientation_offsets, - kinematic_deviations=kinematic_deviations, + type=type, + initial_orientation=initial_orientation, + deviations=deviations, ) @@ -896,37 +848,35 @@ class KinematicLoadConfig: Attributes ---------- - kinematic_type : str + type : str The type of kinematic used. - kinematic_initial_orientation_offsets : KinematicOffsets, optional - The initial orientation offsets of the kinematic configuration. - kinematic_deviations : KinematicDeviations, optional + initial_orientation : torch.Tensor + The initial orientation of the kinematic configuration. + deviations : KinematicDeviations, optional The kinematic deviations. """ def __init__( self, - kinematic_type: str, - kinematic_initial_orientation_offsets: KinematicOffsets, - kinematic_deviations: KinematicDeviations, + type: str, + initial_orientation: torch.Tensor, + deviations: KinematicDeviations, ) -> None: """ Initialize the kinematic configuration for loading in ``ARTIST``. Parameters ---------- - kinematic_type : str + type : str The type of kinematic used. - kinematic_initial_orientation_offsets : KinematicOffsets, optional - The initial orientation offsets of the kinematic configuration. - kinematic_deviations : KinematicDeviations, optional + initial_orientation : torch.Tensor + The initial orientation of the kinematic configuration. + deviations : KinematicDeviations, optional The kinematic deviations. """ - self.kinematic_type = kinematic_type - self.kinematic_initial_orientation_offsets = ( - kinematic_initial_orientation_offsets - ) - self.kinematic_deviations = kinematic_deviations + self.type = type + self.initial_orientation = initial_orientation + self.deviations = deviations class ActuatorParameters: @@ -941,10 +891,10 @@ class ActuatorParameters: The initial stroke length. offset : torch.Tensor, optional The initial actuator offset. - radius : torch.Tensor, optional - The radius of the considered joint. - phi_0 : torch.Tensor, optional - The initial phi value of the actuator. + pivot_radius : torch.Tensor, optional + The pivot radius of the considered joint. + initial_angle : torch.Tensor, optional + The initial angle of the actuator. Methods ------- @@ -957,8 +907,8 @@ def __init__( increment: Optional[torch.Tensor] = None, initial_stroke_length: Optional[torch.Tensor] = None, offset: Optional[torch.Tensor] = None, - radius: Optional[torch.Tensor] = None, - phi_0: Optional[torch.Tensor] = None, + pivot_radius: Optional[torch.Tensor] = None, + initial_angle: Optional[torch.Tensor] = None, ) -> None: """ Initialize the actuator parameters. @@ -971,16 +921,16 @@ def __init__( The initial stroke length. offset : torch.Tensor, optional The initial actuator offset. - radius : torch.Tensor, optional - The radius of the considered joint. - phi_0 : torch.Tensor, optional - The initial phi value of the actuator. + pivot_radius : torch.Tensor, optional + The pivot radius of the considered joint. + initial_angle : torch.Tensor, optional + The initial angle of the actuator. """ self.increment = increment self.initial_stroke_length = initial_stroke_length self.offset = offset - self.radius = radius - self.phi_0 = phi_0 + self.pivot_radius = pivot_radius + self.initial_angle = initial_angle def create_actuator_parameters_dict(self) -> dict[str, torch.Tensor]: """ @@ -1006,13 +956,13 @@ def create_actuator_parameters_dict(self) -> dict[str, torch.Tensor]: actuator_parameters_dict.update( {config_dictionary.actuator_offset: self.offset} ) - if self.radius is not None: + if self.pivot_radius is not None: actuator_parameters_dict.update( - {config_dictionary.actuator_radius: self.radius} + {config_dictionary.actuator_pivot_radius: self.pivot_radius} ) - if self.phi_0 is not None: + if self.initial_angle is not None: actuator_parameters_dict.update( - {config_dictionary.actuator_phi_0: self.phi_0} + {config_dictionary.actuator_initial_angle: self.initial_angle} ) return actuator_parameters_dict @@ -1025,7 +975,7 @@ class ActuatorConfig: ---------- actuator_type : str The type of actuator to use, e.g., linear or ideal. - actuator_clockwise : bool + clockwise_axis_movement : bool Boolean indicating if the actuator operates in a clockwise manner. actuator_parameters : ActuatorParameters, optional The parameters of the actuator @@ -1038,27 +988,29 @@ class ActuatorConfig: def __init__( self, - actuator_key: str, - actuator_type: str, - actuator_clockwise: bool, - actuator_parameters: Optional[ActuatorParameters] = None, + key: str, + type: str, + clockwise_axis_movement: bool, + parameters: Optional[ActuatorParameters] = None, ) -> None: """ Initialize the actuator configuration. Parameters ---------- - actuator_type : str + key : str + The name or descriptor of the actuator. + type : str The type of actuator to use, e.g. linear or ideal. - actuator_clockwise : bool - Boolean indicating if the actuator operates in a clockwise manner. - actuator_parameters : ActuatorParameters, optional + clockwise_axis_movement : bool + Boolean indicating if the actuator operates in a clockwise or counterclockwise manner. + parameters : ActuatorParameters, optional The parameters of the actuator. """ - self.actuator_key = actuator_key - self.actuator_type = actuator_type.lower() - self.actuator_clockwise = actuator_clockwise - self.actuator_parameters = actuator_parameters + self.key = key + self.type = type + self.clockwise_axis_movement = clockwise_axis_movement + self.parameters = parameters def create_actuator_dict(self) -> dict[str, Any]: """ @@ -1070,13 +1022,13 @@ def create_actuator_dict(self) -> dict[str, Any]: A dictionary containing the actuator configuration. """ actuator_dict = { - config_dictionary.actuator_type_key: self.actuator_type, - config_dictionary.actuator_clockwise: self.actuator_clockwise, + config_dictionary.actuator_type_key: self.type, + config_dictionary.actuator_clockwise_axis_movement: self.clockwise_axis_movement, } - if self.actuator_parameters is not None: + if self.parameters is not None: actuator_dict.update( { - config_dictionary.actuator_parameters_key: self.actuator_parameters.create_actuator_parameters_dict() + config_dictionary.actuator_parameters_key: self.parameters.create_actuator_parameters_dict() } ) return actuator_dict @@ -1118,7 +1070,7 @@ def create_actuator_list_dict(self) -> dict[str, Any]: A dictionary containing a list of actuator configurations. """ return { - actuator_config.actuator_key: actuator_config.create_actuator_dict() + actuator_config.key: actuator_config.create_actuator_dict() for actuator_config in self.actuator_list } diff --git a/artist/util/nurbs.py b/artist/util/nurbs.py index 393ed84b8..ecfe8edef 100644 --- a/artist/util/nurbs.py +++ b/artist/util/nurbs.py @@ -34,6 +34,8 @@ class NURBSSurface(torch.nn.Module): Compute the nonzero derivatives of the basis functions up to the nth-derivative. calculate_surface_points_and_normals() Calculate the surface points and normals of the NURBS surface. + forward() + Specify the forward pass. """ def __init__( @@ -417,3 +419,14 @@ def calculate_surface_points_and_normals( ) return derivatives[:, 0, 0], normals + + def forward(self) -> None: + """ + Specify the forward pass. + + Raises + ------ + NotImplementedError + Whenever called. + """ + raise NotImplementedError("Not Implemented!") diff --git a/artist/util/paint_loader.py b/artist/util/paint_loader.py new file mode 100644 index 000000000..f2f1bc9d8 --- /dev/null +++ b/artist/util/paint_loader.py @@ -0,0 +1,391 @@ +import json +import pathlib +from typing import Union + +import torch + +from artist.util import config_dictionary, utils +from artist.util.configuration_classes import ( + ActuatorConfig, + ActuatorListConfig, + ActuatorParameters, + KinematicConfig, + KinematicDeviations, +) + + +def extract_paint_calibration_target_name( + calibration_properties_path: pathlib.Path, +) -> str: + """ + Extract the calibration target name from a ```PAINT`` calibration file for scenario generation. + + Parameters + ---------- + calibration_properties_path : pathlib.Path + The path to the calibration file. + + Returns + ------- + str + The name of the calibration target. + """ + with open(calibration_properties_path, "r") as file: + calibration_dict = json.load(file) + calibration_target_name = calibration_dict[ + config_dictionary.paint_calibration_traget + ] + + return calibration_target_name + + +def extract_paint_calibration_data( + calibration_properties_path: pathlib.Path, + power_plant_position: torch.Tensor, + device: Union[torch.device, str] = "cuda", +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Extract calibration data from a ``PAINT`` calibration file for alignment optimization. + + Parameters + ---------- + calibration_properties_path : pathlib.Path + The path to the calibration properties file. + power_plant_position : torch.Tensor + The position of the power plant in latitude, longitude and elevation. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + torch.Tensor + The calibration flux density center. + torch.Tensor + The incident ray direction. + torch.Tensor + The motor positions. + """ + device = torch.device(device) + with open(calibration_properties_path, "r") as file: + calibration_dict = json.load(file) + center_calibration_image = utils.convert_wgs84_coordinates_to_local_enu( + torch.tensor( + calibration_dict[config_dictionary.paint_focal_spot][ + config_dictionary.paint_utis + ], + dtype=torch.float64, + device=device, + ), + power_plant_position, + device=device, + ) + center_calibration_image = utils.convert_3d_point_to_4d_format( + center_calibration_image, device=device + ) + sun_azimuth = torch.tensor( + calibration_dict[config_dictionary.paint_sun_azimuth], device=device + ) + sun_elevation = torch.tensor( + calibration_dict[config_dictionary.paint_sun_elevation], device=device + ) + incident_ray_direction = utils.convert_3d_direction_to_4d_format( + utils.azimuth_elevation_to_enu(sun_azimuth, sun_elevation, degree=True), + device=device, + ) + motor_positions = torch.tensor( + [ + calibration_dict[config_dictionary.paint_motor_positions][ + config_dictionary.paint_first_axis + ], + calibration_dict[config_dictionary.paint_motor_positions][ + config_dictionary.paint_second_axis + ], + ], + device=device, + ) + + return center_calibration_image, incident_ray_direction, motor_positions + + +def extract_paint_tower_measurements( + tower_measurements_path: pathlib.Path, + target_name: str, + device: Union[torch.device, str] = "cuda", +) -> tuple[torch.Tensor, str, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Extract tower data from a ``PAINT`` tower measurement file for scenario generation. + + Parameters + ---------- + tower_measurements_path : pathlib.Path + The path to the tower measurement file. + target_name : str + The name of the target plane (receiver or calibration target). + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + torch.Tensor + The position of the power plant in latitude, longitude, elevation. + str + The type of the target (i.e. plane). + torch.Tensor + The coordinates of the target center in ENU. + torch.Tensor + The normal vector of the target plane. + torch.Tensor + The dimension of the target plane in east direction (width). + torch.Tensor + The dimension of the target plane in up dimension (height). + """ + with open(tower_measurements_path, "r") as file: + tower_dict = json.load(file) + power_plant_position = torch.tensor( + tower_dict[config_dictionary.paint_power_plant_properties][ + config_dictionary.paint_coordinates + ], + dtype=torch.float64, + device=device, + ) + target_type = tower_dict[target_name][config_dictionary.paint_receiver_type] + target_center_lat_lon = torch.tensor( + tower_dict[target_name][config_dictionary.paint_coordinates][ + config_dictionary.paint_center + ], + dtype=torch.float64, + device=device, + ) + target_center_3d = utils.convert_wgs84_coordinates_to_local_enu( + target_center_lat_lon, power_plant_position, device=device + ) + target_center = utils.convert_3d_point_to_4d_format( + target_center_3d, device=device + ) + normal_vector = utils.convert_3d_direction_to_4d_format( + torch.tensor( + tower_dict[target_name][config_dictionary.paint_normal_vector], + device=device, + ), + device=device, + ) + upper_left = utils.convert_wgs84_coordinates_to_local_enu( + torch.tensor( + tower_dict[target_name][config_dictionary.paint_coordinates][ + config_dictionary.paint_upper_left + ], + dtype=torch.float64, + device=device, + ), + power_plant_position, + device=device, + ) + lower_left = utils.convert_wgs84_coordinates_to_local_enu( + torch.tensor( + tower_dict[target_name][config_dictionary.paint_coordinates][ + config_dictionary.paint_lower_left + ], + dtype=torch.float64, + device=device, + ), + power_plant_position, + device=device, + ) + upper_right = utils.convert_wgs84_coordinates_to_local_enu( + torch.tensor( + tower_dict[target_name][config_dictionary.paint_coordinates][ + config_dictionary.paint_upper_right + ], + dtype=torch.float64, + device=device, + ), + power_plant_position, + device=device, + ) + lower_right = utils.convert_wgs84_coordinates_to_local_enu( + torch.tensor( + tower_dict[target_name][config_dictionary.paint_coordinates][ + config_dictionary.paint_lower_right + ], + dtype=torch.float64, + device=device, + ), + power_plant_position, + device=device, + ) + plane_e, plane_u = utils.corner_points_to_plane( + upper_left, upper_right, lower_left, lower_right + ) + + return ( + power_plant_position, + target_type, + target_center, + normal_vector, + plane_e, + plane_u, + ) + + +def extract_paint_heliostat_properties( + heliostat_properties_path: pathlib.Path, + power_plant_position: torch.Tensor, + device: Union[torch.device, str] = "cuda", +) -> tuple[torch.Tensor, KinematicConfig, ActuatorListConfig]: + """ + Extract heliostat data from a ``PAINT`` heliostat file for scenario generation. + + Parameters + ---------- + heliostat_properties_path : pathlib.Path + The path to the heliostat file. + power_plant_position : str + The position of the power plant in latitude, longitude and elevation. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + torch.Tensor + The position of the heliostat in ENU. + KinematicConfig + The kinematic configuration including type, initial orientation and deviations. + ActuatorListConfig + Configuration for multiple actuators with individual parameters. + """ + with open(heliostat_properties_path, "r") as file: + heliostat_dict = json.load(file) + heliostat_position_3d = utils.convert_wgs84_coordinates_to_local_enu( + torch.tensor( + heliostat_dict[config_dictionary.paint_heliostat_position], + dtype=torch.float64, + device=device, + ), + power_plant_position, + device=device, + ) + heliostat_position = utils.convert_3d_point_to_4d_format( + heliostat_position_3d, device=device + ) + + kinematic_deviations = KinematicDeviations( + first_joint_translation_e=torch.tensor( + heliostat_dict[config_dictionary.paint_kinematic][ + config_dictionary.paint_first_joint_translation_e + ], + device=device, + ), + first_joint_translation_n=torch.tensor( + heliostat_dict[config_dictionary.paint_kinematic][ + config_dictionary.paint_first_joint_translation_n + ], + device=device, + ), + first_joint_translation_u=torch.tensor( + heliostat_dict[config_dictionary.paint_kinematic][ + config_dictionary.paint_first_joint_translation_u + ], + device=device, + ), + first_joint_tilt_e=torch.tensor(0.0, device=device), + first_joint_tilt_n=torch.tensor(0.0, device=device), + first_joint_tilt_u=torch.tensor(0.0, device=device), + second_joint_translation_e=torch.tensor( + heliostat_dict[config_dictionary.paint_kinematic][ + config_dictionary.paint_second_joint_translation_e + ], + device=device, + ), + second_joint_translation_n=torch.tensor( + heliostat_dict[config_dictionary.paint_kinematic][ + config_dictionary.paint_second_joint_translation_n + ], + device=device, + ), + second_joint_translation_u=torch.tensor( + heliostat_dict[config_dictionary.paint_kinematic][ + config_dictionary.paint_second_joint_translation_u + ], + device=device, + ), + second_joint_tilt_e=torch.tensor(0.0, device=device), + second_joint_tilt_n=torch.tensor(0.0, device=device), + second_joint_tilt_u=torch.tensor(0.0, device=device), + concentrator_translation_e=torch.tensor( + heliostat_dict[config_dictionary.paint_kinematic][ + config_dictionary.paint_concentrator_translation_e + ], + device=device, + ), + concentrator_translation_n=torch.tensor( + heliostat_dict[config_dictionary.paint_kinematic][ + config_dictionary.paint_concentrator_translation_n + ], + device=device, + ), + concentrator_translation_u=torch.tensor( + heliostat_dict[config_dictionary.paint_kinematic][ + config_dictionary.paint_concentrator_translation_u + ], + device=device, + ), + concentrator_tilt_e=torch.tensor(0.0, device=device), + concentrator_tilt_n=torch.tensor(0.0, device=device), + concentrator_tilt_u=torch.tensor(0.0, device=device), + ) + + # Include the initial orientation for the kinematic. + initial_orientation = utils.convert_3d_direction_to_4d_format( + torch.tensor( + heliostat_dict[config_dictionary.paint_initial_orientation], + device=device, + ), + device=device, + ) + + # Include the kinematic prototype configuration. + kinematic_config = KinematicConfig( + type=config_dictionary.rigid_body_key, + initial_orientation=initial_orientation, + deviations=kinematic_deviations, + ) + + paint_actuators = list( + heliostat_dict[config_dictionary.paint_kinematic][ + config_dictionary.paint_actuators + ] + ) + actuator_list = [] + + for i, paint_actuator in enumerate(paint_actuators): + parameters = ActuatorParameters( + increment=torch.tensor( + paint_actuator[config_dictionary.paint_increment], device=device + ), + initial_stroke_length=torch.tensor( + paint_actuator[config_dictionary.paint_initial_stroke_length], + device=device, + ), + offset=torch.tensor( + paint_actuator[config_dictionary.paint_offset], device=device + ), + pivot_radius=torch.tensor( + paint_actuator[config_dictionary.paint_pivot_radius], device=device + ), + initial_angle=torch.tensor( + paint_actuator[config_dictionary.paint_initial_angle], device=device + ), + ) + actuator = ActuatorConfig( + key=f"{config_dictionary.heliostat_actuator_key}_{i}", + type=paint_actuator[config_dictionary.paint_actuator_type], + clockwise_axis_movement=paint_actuator[ + config_dictionary.paint_clockwise_axis_movement + ], + parameters=parameters, + ) + actuator_list.append(actuator) + + actuators_list_config = ActuatorListConfig(actuator_list=actuator_list) + + return heliostat_position, kinematic_config, actuators_list_config diff --git a/artist/util/scenario_generator.py b/artist/util/scenario_generator.py index 5e009cc46..92413885f 100644 --- a/artist/util/scenario_generator.py +++ b/artist/util/scenario_generator.py @@ -1,10 +1,8 @@ import logging -import sys +import pathlib from collections.abc import MutableMapping -from pathlib import Path from typing import Any, Generator -import colorlog import h5py import torch @@ -12,10 +10,14 @@ from artist.util.configuration_classes import ( HeliostatListConfig, LightSourceListConfig, + PowerPlantConfig, PrototypeConfig, ReceiverListConfig, ) +log = logging.getLogger(__name__) +"""A logger for the scenario_generator.""" + class ScenarioGenerator: """ @@ -23,8 +25,10 @@ class ScenarioGenerator: Attributes ---------- - file_path : Path + file_path : pathlib.Path File path to the HDF5 to be saved. + power_plant_config : PowerPlantConfig + The power plant configuration object. receiver_list_config : ReceiverListConfig The receiver list configuration object. light_source_list_config : LightSourceListConfig @@ -35,8 +39,6 @@ class ScenarioGenerator: The prototype configuration object, version : Optional[float] The version of the scenario generator being used. - log : logging.Logger - The logger. Methods ------- @@ -50,13 +52,13 @@ class ScenarioGenerator: def __init__( self, - file_path: str, + file_path: pathlib.Path, + power_plant_config: PowerPlantConfig, receiver_list_config: ReceiverListConfig, light_source_list_config: LightSourceListConfig, heliostat_list_config: HeliostatListConfig, prototype_config: PrototypeConfig, version: float = 1.0, - log_level: int = logging.INFO, ) -> None: """ Initialize the scenario generator. @@ -68,8 +70,10 @@ def __init__( Parameters ---------- - file_path : str + file_path : pathlib.Path File path to the HDF5 to be saved. + power_plant_config : PowerPlantConfig + The power plant configuration object. receiver_list_config : ReceiverListConfig The receiver list configuration object. light_source_list_config : LightSourceListConfig @@ -80,41 +84,20 @@ def __init__( The prototype configuration object, version : float The version of the scenario generator being used (default: 1.0). - log_level : int - The log level applied to the logger (default: logging.INFO). """ - self.file_path = Path(file_path) + self.file_path = file_path if not self.file_path.parent.is_dir(): raise FileNotFoundError( f"The folder ``{self.file_path.parent}`` selected to save the scenario does not exist. " "Please create the folder or adjust the file path before running again!" ) + self.power_plant_config = power_plant_config self.receiver_list_config = receiver_list_config self.light_source_list_config = light_source_list_config self.heliostat_list_config = heliostat_list_config self.prototype_config = prototype_config self.check_facet_and_point_size() self.version = version - log = logging.getLogger("scenario-generator") # Get logger instance. - log_formatter = colorlog.ColoredFormatter( - fmt="[%(cyan)s%(asctime)s%(reset)s][%(blue)s%(name)s%(reset)s]" - "[%(log_color)s%(levelname)s%(reset)s] - %(message)s", - datefmt=None, - reset=True, - log_colors={ - "DEBUG": "cyan", - "INFO": "green", - "WARNING": "yellow", - "ERROR": "red", - "CRITICAL": "red,bg_white", - }, - secondary_log_colors={}, - ) - handler = logging.StreamHandler(stream=sys.stdout) - handler.setFormatter(log_formatter) - log.addHandler(handler) - log.setLevel(log_level) - self.log = log def check_facet_and_point_size(self): """ @@ -122,7 +105,7 @@ def check_facet_and_point_size(self): Raises ------ - AssertionError + ValueError If at least one heliostat has a different number of facets or one facet has a different number of evaluation points. """ @@ -138,27 +121,37 @@ def check_facet_and_point_size(self): ].number_eval_points_n ) # Check that every facet in the prototype has the same number of evaluation points - assert all( + if not all( self.prototype_config.surface_prototype.facets_list[i].number_eval_points_e * self.prototype_config.surface_prototype.facets_list[ i ].number_eval_points_n == accepted_number_of_points for i in range(accepted_number_of_facets) - ), "The number of evaluation points for each facet is different in the surface prototype!" + ): + raise ValueError( + "The number of evaluation points for each facet is different in the surface prototype!" + ) + # Check that every heliostat has the same number of facets and evaluation points for heliostat in self.heliostat_list_config.heliostat_list: if heliostat.heliostat_surface: - assert ( + if ( len(heliostat.heliostat_surface.facets_list) - == accepted_number_of_facets - ), "Individual heliostats must all have the same number of facets!" - assert all( + != accepted_number_of_facets + ): + raise ValueError( + "Individual heliostats must all have the same number of facets!" + ) + if not all( heliostat.heliostat_surface.facets_list[i].number_eval_points_e * heliostat.heliostat_surface.facets_list[i].number_eval_points_n == accepted_number_of_points for i in range(accepted_number_of_facets) - ), "The number of evaluation points for each facet is different in the individual heliostat!" + ): + raise ValueError( + "The number of evaluation points for each facet is different in the individual heliostat!" + ) def flatten_dict( self, dictionary: MutableMapping, parent_key: str = "", sep: str = "/" @@ -215,15 +208,25 @@ def include_parameters(file: h5py.File, prefix: str, parameters: dict) -> None: def generate_scenario(self) -> None: """Generate the scenario according to the given parameters.""" - self.log.info(f"Generating a scenario saved to: {self.file_path}") + log.info(f"Generating a scenario saved to: {self.file_path}") save_name = self.file_path.parent / (self.file_path.name + ".h5") with h5py.File(save_name, "w") as f: # Set scenario version as attribute. - self.log.info(f"Using scenario generator version {self.version}") + log.info(f"Using scenario generator version {self.version}") f.attrs["version"] = self.version + # Include parameters for the power plant. + log.info("Including parameters for the power plant") + self.include_parameters( + file=f, + prefix=config_dictionary.power_plant_key, + parameters=self.flatten_dict( + self.power_plant_config.create_power_plant_dict() + ), + ) + # Include parameters for the receivers. - self.log.info("Including parameters for the receivers") + log.info("Including parameters for the receivers") self.include_parameters( file=f, prefix=config_dictionary.receiver_key, @@ -233,7 +236,7 @@ def generate_scenario(self) -> None: ) # Include parameters for the light sources. - self.log.info("Including parameters for the light sources") + log.info("Including parameters for the light sources") self.include_parameters( file=f, prefix=config_dictionary.light_source_key, @@ -243,7 +246,7 @@ def generate_scenario(self) -> None: ) # Include parameters for the prototype. - self.log.info("Including parameters for the prototype") + log.info("Including parameters for the prototype") self.include_parameters( file=f, prefix=config_dictionary.prototype_key, @@ -253,7 +256,7 @@ def generate_scenario(self) -> None: ) # Include heliostat parameters. - self.log.info("Including parameters for the heliostats") + log.info("Including parameters for the heliostats") self.include_parameters( file=f, prefix=config_dictionary.heliostat_key, diff --git a/artist/util/stral_to_surface_converter.py b/artist/util/stral_to_surface_converter.py deleted file mode 100644 index 28d8d7f49..000000000 --- a/artist/util/stral_to_surface_converter.py +++ /dev/null @@ -1,537 +0,0 @@ -import logging -import struct -import sys -from pathlib import Path -from typing import Union - -import colorlog -import torch - -from artist.util import config_dictionary -from artist.util.configuration_classes import FacetConfig -from artist.util.nurbs import NURBSSurface - -log = logging.getLogger("STRAL-to-surface-converter") # Get logger instance. -"""A logger for the ``STRAL`` to surface converter.""" - -log_formatter = colorlog.ColoredFormatter( - fmt="[%(cyan)s%(asctime)s%(reset)s][%(blue)s%(name)s%(reset)s]" - "[%(log_color)s%(levelname)s%(reset)s] - %(message)s", - datefmt=None, - reset=True, - log_colors={ - "DEBUG": "cyan", - "INFO": "green", - "WARNING": "yellow", - "ERROR": "red", - "CRITICAL": "red,bg_white", - }, - secondary_log_colors={}, -) -"""A formatter for the logger for the ``STRAL`` to surface converter.""" -handler = logging.StreamHandler(stream=sys.stdout) -"""A handler for the logger for the ``STRAL`` to surface converter.""" -handler.setFormatter(log_formatter) -log.addHandler(handler) -log.setLevel(logging.INFO) - - -class StralToSurfaceConverter: - """ - Implement a converter that converts ``STRAL`` data to HDF5 format. - - Attributes - ---------- - stral_file_path : str - The file path to the ``STRAL`` data file that will be converted. - surface_header_name : str - The name for the concentrator header in the ``STRAL`` file. - facet_header_name : str - The name for the facet header in the ``STRAL`` file. - points_on_facet_struct_name : str - The name of the ray structure in the ``STRAL`` file. - step_size : int - The size of the step used to reduce the number of considered points for compute efficiency. - - Methods - ------- - convert_3d_points_to_4d_format() - Convert a 3D point to 4D format. - convert_3d_direction_to_4d_format() - Convert a 3D direction vector to 4D format. - nwu_to_enu() - Cast from an NWU to an ENU coordinate system. - normalize_evaluation_points_for_nurbs() - Normalize evaluation points for NURBS with minimum > 0 and maximum < 1. - fit_nurbs_surface() - Fit the nurbs surface given the conversion method. - generate_surface_config_from_stral() - Generate a surface configuration based on the ``STRAL`` data. - """ - - def __init__( - self, - stral_file_path: str, - surface_header_name: str, - facet_header_name: str, - points_on_facet_struct_name: str, - step_size: int, - ) -> None: - """ - Initialize the converter. - - Heliostat data, including information regarding their surfaces and structure, can be generated via ``STRAL`` and - exported to a binary file. To convert this data into a surface configuration format suitable for ``ARTIST``, - this converter first loads the data and then learns NURBS surfaces based on the data. Finally, the converter - returns a list of facets that can be used directly in an ``ARTIST`` scenario. - - Parameters - ---------- - stral_file_path : str - The file path to the ``STRAL`` data file that will be converted. - surface_header_name : str - The name for the surface header in the ``STRAL`` file. - facet_header_name : str - The name for the facet header in the ``STRAL`` file. - points_on_facet_struct_name : str - The name of the point on facet structure in the ``STRAL`` file. - step_size : int - The size of the step used to reduce the number of considered points for compute efficiency. - """ - self.stral_file_path = Path(stral_file_path) - self.surface_header_name = surface_header_name - self.facet_header_name = facet_header_name - self.points_on_facet_struct_name = points_on_facet_struct_name - self.step_size = step_size - - @staticmethod - def convert_3d_points_to_4d_format( - point: torch.Tensor, device: Union[torch.device, str] = "cuda" - ) -> torch.Tensor: - """ - Append ones to the last dimension of a 3D point vector. - - Includes the convention that points have a 1 and directions have 0 as 4th dimension. - - Parameters - ---------- - point : torch.Tensor - Input point in a 3D format. - device : Union[torch.device, str] - The device on which to initialize tensors (default is cuda). - - Returns - ------- - torch.Tensor - Point vector with ones appended at the last dimension. - """ - device = torch.device(device) - assert ( - point.size(dim=-1) == 3 - ), f"Expected a 3D point but got a point of shape {point.shape}!" - ones_tensor = torch.ones( - point.shape[:-1] + (1,), dtype=point.dtype, device=device - ) - return torch.cat((point, ones_tensor), dim=-1) - - @staticmethod - def convert_3d_direction_to_4d_format( - direction: torch.Tensor, device: Union[torch.device, str] = "cuda" - ) -> torch.Tensor: - """ - Append zeros to the last dimension of a 3D direction vector. - - Includes the convention that points have a 1 and directions have 0 as 4th dimension. - - Parameters - ---------- - direction : torch.Tensor - Input direction in a 3D format. - device : Union[torch.device, str] - The device on which to initialize tensors (default is cuda). - - Returns - ------- - torch.Tensor - Direction vector with ones appended at the last dimension. - """ - device = torch.device(device) - assert ( - direction.size(dim=-1) == 3 - ), f"Expected a 3D direction vector but got a director vector of shape {direction.shape}!" - zeros_tensor = torch.zeros( - direction.shape[:-1] + (1,), dtype=direction.dtype, device=device - ) - return torch.cat((direction, zeros_tensor), dim=-1) - - @staticmethod - def nwu_to_enu( - nwu_tensor: torch.Tensor, device: Union[torch.device, str] = "cuda" - ) -> torch.Tensor: - """ - Cast the coordinate system from NWU to ENU. - - Parameters - ---------- - nwu_tensor : torch.Tensor - The tensor in the NWU coordinate system. - device : Union[torch.device, str] - The device on which to initialize tensors (default is cuda). - - Returns - ------- - torch.Tensor - The converted tensor in the ENU coordinate system. - """ - device = torch.device(device) - return torch.tensor( - [-nwu_tensor[1], nwu_tensor[0], nwu_tensor[2]], - dtype=torch.float, - device=device, - ) - - @staticmethod - def normalize_evaluation_points_for_nurbs(points: torch.Tensor) -> torch.Tensor: - """ - Normalize the evaluation points for NURBS. - - This function normalizes the evaluation points for NURBS in the open interval of (0,1) since NURBS are not - defined for the edges. - - Parameters - ---------- - points : torch.Tensor - The evaluation points for NURBS. - - Returns - ------- - torch.Tensor - The normalized evaluation points for NURBS. - """ - # Since NURBS are only defined between (0,1), a small offset is required to exclude the boundaries from the - # defined evaluation points. - points_normalized = (points[:] - min(points[:]) + 1e-5) / max( - (points[:] - min(points[:])) + 2e-5 - ) - return points_normalized - - def fit_nurbs_surface( - self, - surface_points: torch.Tensor, - surface_normals: torch.Tensor, - conversion_method: str, - number_control_points_e: int = 10, - number_control_points_n: int = 10, - degree_e: int = 2, - degree_n: int = 2, - tolerance: float = 1e-5, - initial_learning_rate: float = 1e-1, - max_epoch: int = 2500, - device: Union[torch.device, str] = "cuda", - ) -> NURBSSurface: - """ - Generate a NURBS surface based on ``STRAL`` data. - - The surface points are first normalized and shifted to the range (0,1) to be compatible with the knot vector of - the NURBS surface. The NURBS surface is then initialized with the correct number of control points, degrees, and - knots, and the origin of the control points is set based on the width and height of the point cloud. The control - points are then fitted to the surface points or surface normals using an Adam optimizer. - The optimization stops when the loss is less than the tolerance or the maximum number of epochs is reached. - - Parameters - ---------- - surface_points : torch.Tensor - The surface points given as an (N, 4) tensor. - surface_normals : torch.Tensor - The surface normals given as an (N, 4) tensor. - conversion_method : str - The conversion method used to learn the NURBS. - number_control_points_e : int - Number of NURBS control points to be set in the east (first) direction (default: 10). - number_control_points_n : int - Number of NURBS control points to be set in the north (second) direction (default: 10). - degree_e : int - Degree of the NURBS in the east (first) direction (default: 2). - degree_n : int - Degree of the NURBS in the north (second) direction (default: 2). - tolerance : float, optional - Tolerance value for convergence criteria (default: 1e-5). - initial_learning_rate : float - Initial learning rate for the learning rate scheduler (default: 1e-1). - max_epoch : int, optional - Maximum number of epochs for optimization (default: 2500). - device : Union[torch.device, str] - The device on which to initialize tensors (default is cuda). - - Returns - ------- - NURBSSurface - A NURBS surface. - """ - device = torch.device(device) - - # Normalize evaluation points and shift them so that they correspond to the knots. - evaluation_points = surface_points.clone() - evaluation_points[:, 2] = 0 - evaluation_points_e = self.normalize_evaluation_points_for_nurbs( - evaluation_points[:, 0] - ) - evaluation_points_n = self.normalize_evaluation_points_for_nurbs( - evaluation_points[:, 1] - ) - - # Initialize the NURBS surface. - control_points_shape = (number_control_points_e, number_control_points_n) - control_points = torch.zeros(control_points_shape + (3,), device=device) - width_of_nurbs = torch.max(evaluation_points[:, 0]) - torch.min( - evaluation_points[:, 0] - ) - height_of_nurbs = torch.max(evaluation_points[:, 1]) - torch.min( - evaluation_points[:, 1] - ) - origin_offsets_e = torch.linspace( - -width_of_nurbs / 2, - width_of_nurbs / 2, - number_control_points_e, - device=device, - ) - origin_offsets_n = torch.linspace( - -height_of_nurbs / 2, - height_of_nurbs / 2, - number_control_points_n, - device=device, - ) - origin_offsets = torch.cartesian_prod(origin_offsets_e, origin_offsets_n) - origin_offsets = torch.hstack( - ( - origin_offsets, - torch.zeros((len(origin_offsets), 1), device=device), - ) - ) - control_points = torch.nn.parameter.Parameter( - origin_offsets.reshape(control_points.shape) - ) - nurbs_surface = NURBSSurface( - degree_e, - degree_n, - evaluation_points_e, - evaluation_points_n, - control_points, - device=device, - ) - - # Optimize the control points of the NURBS surface. - optimizer = torch.optim.Adam([control_points], lr=initial_learning_rate) - scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( - optimizer, - mode="min", - factor=0.2, - patience=50, - threshold=1e-7, - threshold_mode="abs", - ) - loss = torch.inf - epoch = 0 - while loss > tolerance and epoch <= max_epoch: - points, normals = nurbs_surface.calculate_surface_points_and_normals( - device=device - ) - - optimizer.zero_grad() - - if conversion_method == config_dictionary.convert_nurbs_from_points: - loss = (points - surface_points).abs().mean() - elif conversion_method == config_dictionary.convert_nurbs_from_normals: - loss = (normals - surface_normals).abs().mean() - else: - raise NotImplementedError( - f"Conversion method {conversion_method} not yet implemented!" - ) - loss.backward() - - optimizer.step() - scheduler.step(loss.abs().mean()) - if epoch % 100 == 0: - log.info( - f"Epoch: {epoch}, Loss: {loss.abs().mean().item()}, LR: {optimizer.param_groups[0]['lr']}", - ) - epoch += 1 - - return nurbs_surface - - def generate_surface_config_from_stral( - self, - number_eval_points_e: int, - number_eval_points_n: int, - conversion_method: str, - number_control_points_e: int = 10, - number_control_points_n: int = 10, - degree_e: int = 2, - degree_n: int = 2, - tolerance: float = 1e-5, - initial_learning_rate: float = 1e-1, - max_epoch: int = 10000, - device: Union[torch.device, str] = "cuda", - ) -> list[FacetConfig]: - """ - Generate a surface configuration from a ``STRAL`` file. - - Parameters - ---------- - number_eval_points_e : int - The number of evaluation points in the east direction used to generate a discrete surface from NURBS. - number_eval_points_n : int - The number of evaluation points in the north direction used to generate a discrete surface from NURBS. - conversion_method : str - The conversion method used to learn the NURBS. - number_control_points_e : int - Number of NURBS control points in the east direction (default: 10). - number_control_points_n : int - Number of NURBS control points in the north direction (default: 10). - degree_e : int - Degree of the NURBS in the east (first) direction (default: 2). - degree_n : int - Degree of the NURBS in the north (second) direction (default: 2). - tolerance : float, optional - Tolerance value used for fitting NURBS surfaces (default: 1e-5). - initial_learning_rate : float - Initial learning rate for the learning rate scheduler used when fitting NURBS surfaces (default: 1e-1). - max_epoch : int - Maximum number of epochs to use when fitting NURBS surfaces (default: 10000). - device : Union[torch.device, str] - The device on which to initialize tensors (default is cuda). - - Returns - ------- - list[FacetConfig] - A list of facet configurations used to generate a surface. - """ - log.info( - "Beginning generation of the surface configuration based on STRAL data." - ) - device = torch.device(device) - - # Create structures for reading ``STRAL`` file correctly. - surface_header_struct = struct.Struct(self.surface_header_name) - facet_header_struct = struct.Struct(self.facet_header_name) - points_on_facet_struct = struct.Struct(self.points_on_facet_struct_name) - log.info(f"Reading STRAL file located at: {self.stral_file_path}") - with open(f"{self.stral_file_path}.binp", "rb") as file: - surface_header_data = surface_header_struct.unpack_from( - file.read(surface_header_struct.size) - ) - # Load width and height. - width, height = surface_header_data[3:5] - - # Calculate the number of facets. - n_xy = surface_header_data[5:7] - number_of_facets = n_xy[0] * n_xy[1] - - # Create empty tensors for storing data. - facet_translation_vectors = torch.empty(number_of_facets, 3, device=device) - canting_e = torch.empty(number_of_facets, 3, device=device) - canting_n = torch.empty(number_of_facets, 3, device=device) - surface_points_with_facets = torch.empty(0, device=device) - surface_normals_with_facets = torch.empty(0, device=device) - for f in range(number_of_facets): - facet_header_data = facet_header_struct.unpack_from( - file.read(facet_header_struct.size) - ) - - facet_translation_vectors[f] = torch.tensor( - facet_header_data[1:4], dtype=torch.float, device=device - ) - canting_n[f] = self.nwu_to_enu( - torch.tensor( - facet_header_data[4:7], dtype=torch.float, device=device - ) - ) - canting_e[f] = self.nwu_to_enu( - torch.tensor( - facet_header_data[7:10], dtype=torch.float, device=device - ) - ) - number_of_points = facet_header_data[10] - if f == 0: - surface_points_with_facets = torch.empty( - number_of_facets, number_of_points, 3, device=device - ) - surface_normals_with_facets = torch.empty( - number_of_facets, number_of_points, 3, device=device - ) - - points_data = points_on_facet_struct.iter_unpack( - file.read(points_on_facet_struct.size * number_of_points) - ) - for i, point_data in enumerate(points_data): - surface_points_with_facets[f, i, :] = torch.tensor( - point_data[:3], dtype=torch.float, device=device - ) - surface_normals_with_facets[f, i, :] = torch.tensor( - point_data[3:6], dtype=torch.float, device=device - ) - - log.info("Loading STRAL data complete") - - # STRAL uses two different coordinate systems, both use a west orientation and therefore, we don't need an NWU - # to ENU cast here. However, to maintain consistency we cast the west direction to east direction. - canting_e[:, 0] = -canting_e[:, 0] - - # Select only selected number of points to reduce compute. - surface_points_with_facets = surface_points_with_facets[:, :: self.step_size] - surface_normals_with_facets = surface_normals_with_facets[:, :: self.step_size] - - # Convert to 4D format. - facet_translation_vectors = self.convert_3d_direction_to_4d_format( - facet_translation_vectors, device=device - ) - # If we are learning the surface points from ``STRAL``, we do not need to translate the facets. - if conversion_method == config_dictionary.convert_nurbs_from_points: - facet_translation_vectors = torch.zeros( - facet_translation_vectors.shape, device=device - ) - # Convert to 4D format. - canting_n = self.convert_3d_direction_to_4d_format(canting_n, device=device) - canting_e = self.convert_3d_direction_to_4d_format(canting_e, device=device) - surface_points_with_facets = self.convert_3d_points_to_4d_format( - surface_points_with_facets, device=device - ) - surface_normals_with_facets = self.convert_3d_direction_to_4d_format( - surface_normals_with_facets, device=device - ) - - # Convert to NURBS surface. - log.info("Converting to NURBS surface") - facet_config_list = [] - for i in range(number_of_facets): - log.info(f"Converting facet {i+1} of {number_of_facets}.") - nurbs_surface = self.fit_nurbs_surface( - surface_points=surface_points_with_facets[i], - surface_normals=surface_normals_with_facets[i], - conversion_method=conversion_method, - number_control_points_e=number_control_points_e, - number_control_points_n=number_control_points_n, - degree_e=degree_e, - degree_n=degree_n, - tolerance=tolerance, - initial_learning_rate=initial_learning_rate, - max_epoch=max_epoch, - device=device, - ) - facet_config_list.append( - FacetConfig( - facet_key=f"facet{i+1}", - control_points=nurbs_surface.control_points.detach(), - degree_e=nurbs_surface.degree_e, - degree_n=nurbs_surface.degree_n, - number_eval_points_e=number_eval_points_e, - number_eval_points_n=number_eval_points_n, - width=width, - height=height, - translation_vector=facet_translation_vectors[i], - canting_e=canting_e[i], - canting_n=canting_n[i], - ) - ) - log.info("Surface configuration based on STRAL data complete!") - return facet_config_list diff --git a/artist/util/surface_converter.py b/artist/util/surface_converter.py new file mode 100644 index 000000000..eb038dd34 --- /dev/null +++ b/artist/util/surface_converter.py @@ -0,0 +1,577 @@ +import json +import logging +import pathlib +import struct +from typing import Union + +import h5py +import numpy as np +import torch + +from artist.util import config_dictionary, utils +from artist.util.configuration_classes import FacetConfig +from artist.util.nurbs import NURBSSurface + +log = logging.getLogger(__name__) +"""A logger for the surface converter.""" + + +class SurfaceConverter: + """ + Implement a converter that converts surface data from various sources to HDF5 format. + + Currently the surface converter can be used for ``STRAL`` or ``PAINT`` data. + + Attributes + ---------- + step_size : int + The size of the step used to reduce the number of considered points for compute efficiency. + number_eval_points_e : int + The number of evaluation points in the east direction used to generate a discrete surface from NURBS. + number_eval_points_n : int + The number of evaluation points in the north direction used to generate a discrete surface from NURBS. + conversion_method : str + The conversion method used to learn the NURBS. + number_control_points_e : int + Number of NURBS control points in the east direction. + number_control_points_n : int + Number of NURBS control points in the north direction. + degree_e : int + Degree of the NURBS in the east (first) direction. + degree_n : int + Degree of the NURBS in the north (second) direction. + tolerance : float, optional + Tolerance value used for fitting NURBS surfaces. + initial_learning_rate : float + Initial learning rate for the learning rate scheduler used when fitting NURBS surfaces. + max_epoch : int + Maximum number of epochs to use when fitting NURBS surfaces. + + Methods + ------- + fit_nurbs_surface() + Fit the NURBS surface given the conversion method. + generate_surface_config_from_stral() + Generate a surface configuration from a ``STRAL`` file. + generate_surface_config_from_paint() + Generate a surface configuration from a ``PAINT`` dataset. + """ + + def __init__( + self, + step_size: int = 100, + number_eval_points_e: int = 100, + number_eval_points_n: int = 100, + conversion_method: str = config_dictionary.convert_nurbs_from_normals, + number_control_points_e: int = 20, + number_control_points_n: int = 20, + degree_e: int = 3, + degree_n: int = 3, + tolerance: float = 3e-5, + initial_learning_rate: float = 1e-3, + max_epoch: int = 10000, + ) -> None: + """ + Initialize the converter. + + Heliostat data, including information regarding their surfaces and structure, can be generated via ``STRAL`` and + exported to a binary file or downloaded from ```PAINT``. The data formats are different depending on their source. + To convert this data into a surface configuration format suitable for ``ARTIST``, this converter first loads the + data and then learns NURBS surfaces based on the data. Finally, the converter returns a list of facets that can + be used directly in an ``ARTIST`` scenario. + + Parameters + ---------- + step_size : int + The size of the step used to reduce the number of considered points for compute efficiency (default is 100). + number_eval_points_e : int + The number of evaluation points in the east direction used to generate a discrete surface from NURBS. + number_eval_points_n : int + The number of evaluation points in the north direction used to generate a discrete surface from NURBS. + conversion_method : str + The conversion method used to learn the NURBS. + number_control_points_e : int + Number of NURBS control points in the east direction (default: 10). + number_control_points_n : int + Number of NURBS control points in the north direction (default: 10). + degree_e : int + Degree of the NURBS in the east (first) direction (default: 2). + degree_n : int + Degree of the NURBS in the north (second) direction (default: 2). + tolerance : float, optional + Tolerance value used for fitting NURBS surfaces (default: 1e-5). + initial_learning_rate : float + Initial learning rate for the learning rate scheduler used when fitting NURBS surfaces (default: 1e-1). + max_epoch : int + Maximum number of epochs to use when fitting NURBS surfaces (default: 10000). + + """ + self.step_size = step_size + + self.number_eval_points_e = number_eval_points_e + self.number_eval_points_n = number_eval_points_n + self.conversion_method = conversion_method + self.number_control_points_e = number_control_points_e + self.number_control_points_n = number_control_points_n + self.degree_e = degree_e + self.degree_n = degree_n + self.tolerance = tolerance + self.initial_learning_rate = initial_learning_rate + self.max_epoch = max_epoch + + def fit_nurbs_surface( + self, + surface_points: torch.Tensor, + surface_normals: torch.Tensor, + conversion_method: str, + number_control_points_e: int = 10, + number_control_points_n: int = 10, + degree_e: int = 2, + degree_n: int = 2, + tolerance: float = 1e-5, + initial_learning_rate: float = 1e-1, + max_epoch: int = 2500, + device: Union[torch.device, str] = "cuda", + ) -> NURBSSurface: + """ + Fit the NURBS surface given the conversion method. + + The surface points are first normalized and shifted to the range (0,1) to be compatible with the knot vector of + the NURBS surface. The NURBS surface is then initialized with the correct number of control points, degrees, and + knots, and the origin of the control points is set based on the width and height of the point cloud. The control + points are then fitted to the surface points or surface normals using an Adam optimizer. + The optimization stops when the loss is less than the tolerance or the maximum number of epochs is reached. + + Parameters + ---------- + surface_points : torch.Tensor + The surface points given as an (N, 4) tensor. + surface_normals : torch.Tensor + The surface normals given as an (N, 4) tensor. + conversion_method : str + The conversion method used to learn the NURBS. + number_control_points_e : int + Number of NURBS control points to be set in the east (first) direction (default: 10). + number_control_points_n : int + Number of NURBS control points to be set in the north (second) direction (default: 10). + degree_e : int + Degree of the NURBS in the east (first) direction (default: 2). + degree_n : int + Degree of the NURBS in the north (second) direction (default: 2). + tolerance : float, optional + Tolerance value for convergence criteria (default: 1e-5). + initial_learning_rate : float + Initial learning rate for the learning rate scheduler (default: 1e-1). + max_epoch : int, optional + Maximum number of epochs for optimization (default: 2500). + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + NURBSSurface + A NURBS surface. + """ + device = torch.device(device) + # Since NURBS are only defined between (0,1), we need to normalize the evaluation points and remove the boundary points. + evaluation_points = surface_points.clone() + evaluation_points[:, 2] = 0 + evaluation_points_e = utils.normalize_points(evaluation_points[:, 0]) + evaluation_points_n = utils.normalize_points(evaluation_points[:, 1]) + + # Initialize the NURBS surface. + control_points_shape = (number_control_points_e, number_control_points_n) + control_points = torch.zeros(control_points_shape + (3,), device=device) + width_of_nurbs = torch.max(evaluation_points[:, 0]) - torch.min( + evaluation_points[:, 0] + ) + height_of_nurbs = torch.max(evaluation_points[:, 1]) - torch.min( + evaluation_points[:, 1] + ) + origin_offsets_e = torch.linspace( + -width_of_nurbs / 2, + width_of_nurbs / 2, + number_control_points_e, + device=device, + ) + origin_offsets_n = torch.linspace( + -height_of_nurbs / 2, + height_of_nurbs / 2, + number_control_points_n, + device=device, + ) + origin_offsets = torch.cartesian_prod(origin_offsets_e, origin_offsets_n) + origin_offsets = torch.hstack( + ( + origin_offsets, + torch.zeros((len(origin_offsets), 1), device=device), + ) + ) + control_points = torch.nn.parameter.Parameter( + origin_offsets.reshape(control_points.shape) + ) + nurbs_surface = NURBSSurface( + degree_e, + degree_n, + evaluation_points_e, + evaluation_points_n, + control_points, + device=device, + ) + + # Optimize the control points of the NURBS surface. + optimizer = torch.optim.Adam([control_points], lr=initial_learning_rate) + scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( + optimizer, + mode="min", + factor=0.2, + patience=50, + threshold=1e-7, + threshold_mode="abs", + ) + loss = torch.inf + epoch = 0 + while loss > tolerance and epoch <= max_epoch: + points, normals = nurbs_surface.calculate_surface_points_and_normals( + device=device + ) + + optimizer.zero_grad() + + if conversion_method == config_dictionary.convert_nurbs_from_points: + loss = (points - surface_points).abs().mean() + elif conversion_method == config_dictionary.convert_nurbs_from_normals: + loss = (normals - surface_normals).abs().mean() + else: + raise NotImplementedError( + f"Conversion method {conversion_method} not yet implemented!" + ) + loss.backward() + + optimizer.step() + scheduler.step(loss.abs().mean()) + if epoch % 100 == 0: + log.info( + f"Epoch: {epoch}, Loss: {loss.abs().mean().item()}, LR: {optimizer.param_groups[0]['lr']}", + ) + epoch += 1 + + return nurbs_surface + + def _generate_surface_config( + self, + surface_points_with_facets_list: list[torch.Tensor], + surface_normals_with_facets_list: list[torch.Tensor], + facet_translation_vectors: torch.Tensor, + canting_e: torch.Tensor, + canting_n: torch.Tensor, + device: Union[torch.device, str] = "cuda", + ) -> list[FacetConfig]: + """ + Generate a surface configuration from a data source. + + Parameters + ---------- + surface_points_with_facets_list : list[torch.Tensor] + A list of facetted surface points. Points per facet may vary. + surface_normals_with_facets_list : list[torch.Tensor] + A list of facetted surface normals. Normals per facet may vary. + facet_translation_vectors : torch.Tensor + Translation vector for each facet from heliostat origin to relative position. + canting_e : torch.Tensor + The canting vector per facet in east direction. + canting_n : torch.Tensor + The canting vector per facet in north direction. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + list[FacetConfig] + A list of facet configurations used to generate a surface. + """ + log.info("Beginning generation of the surface configuration based on data.") + device = torch.device(device) + + # All single_facet_surface_points and single_facet_surface_normals must have the same + # dimensions, so that they can be stacked into a single tensor and then can be used by artist. + min_x = min( + single_facet_surface_points.shape[0] + for single_facet_surface_points in surface_points_with_facets_list + ) + reduced_single_facet_surface_points = [ + single_facet_surface_points[:min_x] + for single_facet_surface_points in surface_points_with_facets_list + ] + surface_points_with_facets = torch.stack(reduced_single_facet_surface_points) + + min_x = min( + single_facet_surface_normals.shape[0] + for single_facet_surface_normals in surface_normals_with_facets_list + ) + reduced_single_facet_surface_normals = [ + single_facet_surface_normals[:min_x] + for single_facet_surface_normals in surface_normals_with_facets_list + ] + surface_normals_with_facets = torch.stack(reduced_single_facet_surface_normals) + + # Select only selected number of points to reduce compute. + surface_points_with_facets = surface_points_with_facets[:, :: self.step_size] + surface_normals_with_facets = surface_normals_with_facets[:, :: self.step_size] + + # Convert to 4D format. + facet_translation_vectors = utils.convert_3d_direction_to_4d_format( + facet_translation_vectors, device=device + ) + # If we are using a point cloud to learn the points, we do not need to translate the facets. + if self.conversion_method == config_dictionary.convert_nurbs_from_points: + facet_translation_vectors = torch.zeros( + facet_translation_vectors.shape, device=device + ) + # Convert to 4D format. + canting_n = utils.convert_3d_direction_to_4d_format(canting_n, device=device) + canting_e = utils.convert_3d_direction_to_4d_format(canting_e, device=device) + surface_points_with_facets = utils.convert_3d_point_to_4d_format( + surface_points_with_facets, device=device + ) + surface_normals_with_facets = utils.convert_3d_direction_to_4d_format( + surface_normals_with_facets, device=device + ) + + # Convert to NURBS surface. + log.info("Converting to NURBS surface") + facet_config_list = [] + for i in range(surface_points_with_facets.shape[0]): + log.info( + f"Converting facet {i+1} of {surface_points_with_facets.shape[0]}." + ) + nurbs_surface = self.fit_nurbs_surface( + surface_points=surface_points_with_facets[i], + surface_normals=surface_normals_with_facets[i], + conversion_method=self.conversion_method, + number_control_points_e=self.number_control_points_e, + number_control_points_n=self.number_control_points_n, + degree_e=self.degree_e, + degree_n=self.degree_n, + tolerance=self.tolerance, + initial_learning_rate=self.initial_learning_rate, + max_epoch=self.max_epoch, + device=device, + ) + facet_config_list.append( + FacetConfig( + facet_key=f"facet_{i+1}", + control_points=nurbs_surface.control_points.detach(), + degree_e=nurbs_surface.degree_e, + degree_n=nurbs_surface.degree_n, + number_eval_points_e=self.number_eval_points_e, + number_eval_points_n=self.number_eval_points_n, + translation_vector=facet_translation_vectors[i], + canting_e=canting_e[i], + canting_n=canting_n[i], + ) + ) + log.info("Surface configuration based on data complete!") + return facet_config_list + + def generate_surface_config_from_stral( + self, + stral_file_path: pathlib.Path, + device: Union[torch.device, str] = "cuda", + ) -> list[FacetConfig]: + """ + Generate a surface configuration from a ``STRAL`` file. + + Parameters + ---------- + stral_file_path : pathlib.Path + The file path to the ``STRAL`` data that will be converted. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + list[FacetConfig] + A list of facet configurations used to generate a surface. + """ + log.info("Beginning extraction of data from ```STRAL``` file.") + device = torch.device(device) + + # Create structures for reading ``STRAL`` file. + surface_header_struct = struct.Struct("=5f2I2f") + facet_header_struct = struct.Struct("=i9fI") + points_on_facet_struct = struct.Struct("=7f") + log.info(f"Reading STRAL file located at: {stral_file_path}") + with open(f"{stral_file_path}", "rb") as file: + surface_header_data = surface_header_struct.unpack_from( + file.read(surface_header_struct.size) + ) + + # Calculate the number of facets. + n_xy = surface_header_data[5:7] + number_of_facets = n_xy[0] * n_xy[1] + + # Create empty tensors for storing data. + facet_translation_vectors = torch.empty(number_of_facets, 3, device=device) + canting_e = torch.empty(number_of_facets, 3, device=device) + canting_n = torch.empty(number_of_facets, 3, device=device) + surface_points_with_facets_list = [] + surface_normals_with_facets_list = [] + for f in range(number_of_facets): + facet_header_data = facet_header_struct.unpack_from( + file.read(facet_header_struct.size) + ) + facet_translation_vectors[f] = torch.tensor( + facet_header_data[1:4], dtype=torch.float, device=device + ) + canting_e[f] = torch.tensor( + facet_header_data[4:7], dtype=torch.float, device=device + ) + canting_n[f] = torch.tensor( + facet_header_data[7:10], dtype=torch.float, device=device + ) + number_of_points = facet_header_data[10] + single_facet_surface_points = torch.empty( + number_of_points, 3, device=device + ) + single_facet_surface_normals = torch.empty( + number_of_points, 3, device=device + ) + + points_data = points_on_facet_struct.iter_unpack( + file.read(points_on_facet_struct.size * number_of_points) + ) + for i, point_data in enumerate(points_data): + single_facet_surface_points[i, :] = torch.tensor( + point_data[:3], dtype=torch.float, device=device + ) + single_facet_surface_normals[i, :] = torch.tensor( + point_data[3:6], dtype=torch.float, device=device + ) + surface_points_with_facets_list.append(single_facet_surface_points) + surface_normals_with_facets_list.append(single_facet_surface_normals) + + log.info("Loading ``STRAL`` data complete") + + surface_config = self._generate_surface_config( + surface_points_with_facets_list=surface_points_with_facets_list, + surface_normals_with_facets_list=surface_normals_with_facets_list, + facet_translation_vectors=facet_translation_vectors, + canting_e=canting_e, + canting_n=canting_n, + device=device, + ) + + return surface_config + + def generate_surface_config_from_paint( + self, + heliostat_file_path: pathlib.Path, + deflectometry_file_path: pathlib.Path, + device: Union[torch.device, str] = "cuda", + ) -> list[FacetConfig]: + """ + Generate a surface configuration from a ``PAINT`` dataset. + + Parameters + ---------- + deflectometry_file_path : pathlib.Path + The file path to the ``PAINT`` deflectometry data that will be converted. + heliostat_file_path : pathlib.Path + The file path to the ``PAINT`` heliostat properties data that will be converted. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + list[FacetConfig] + A list of facet configurations used to generate a surface. + """ + log.info("Beginning extraction of data from ```PAINT``` file.") + # Reading ``PAINT`` heliostat json file. + with open(heliostat_file_path, "r") as file: + heliostat_dict = json.load(file) + number_of_facets = heliostat_dict[config_dictionary.paint_facet_properties][ + config_dictionary.paint_number_of_facets + ] + + facet_translation_vectors = torch.empty(number_of_facets, 3, device=device) + canting_e = torch.empty(number_of_facets, 3, device=device) + canting_n = torch.empty(number_of_facets, 3, device=device) + + for facet in range(number_of_facets): + facet_translation_vectors[facet, :] = torch.tensor( + heliostat_dict[config_dictionary.paint_facet_properties][ + config_dictionary.paint_facets + ][facet][config_dictionary.paint_translation_vetor], + device=device, + ) + canting_e[facet, :] = torch.tensor( + heliostat_dict[config_dictionary.paint_facet_properties][ + config_dictionary.paint_facets + ][facet][config_dictionary.paint_canting_e], + device=device, + ) + canting_n[facet, :] = torch.tensor( + heliostat_dict[config_dictionary.paint_facet_properties][ + config_dictionary.paint_facets + ][facet][config_dictionary.paint_canting_n], + device=device, + ) + + # Reading ``PAINT`` deflectometry hdf5 file. + log.info( + f"Reading PAINT deflectometry file located at: {deflectometry_file_path}" + ) + with h5py.File(deflectometry_file_path, "r") as file: + surface_points_with_facets_list = [] + surface_normals_with_facets_list = [] + for f in range(number_of_facets): + number_of_points = len( + file[f"{config_dictionary.paint_facet}{f+1}"][ + config_dictionary.paint_surface_points + ] + ) + single_facet_surface_points = torch.empty( + number_of_points, 3, device=device + ) + single_facet_surface_normals = torch.empty( + number_of_points, 3, device=device + ) + + points_data = torch.tensor( + np.array( + file[f"{config_dictionary.paint_facet}{f+1}"][ + config_dictionary.paint_surface_points + ] + ), + device=device, + ) + normals_data = torch.tensor( + np.array( + file[f"{config_dictionary.paint_facet}{f+1}"][ + config_dictionary.paint_surface_normals + ] + ), + device=device, + ) + + for i, point_data in enumerate(points_data): + single_facet_surface_points[i, :] = point_data + for i, normal_data in enumerate(normals_data): + single_facet_surface_normals[i, :] = normal_data + surface_points_with_facets_list.append(single_facet_surface_points) + surface_normals_with_facets_list.append(single_facet_surface_normals) + + log.info("Loading ``PAINT`` data complete") + + surface_config = self._generate_surface_config( + surface_points_with_facets_list=surface_points_with_facets_list, + surface_normals_with_facets_list=surface_normals_with_facets_list, + facet_translation_vectors=facet_translation_vectors, + canting_e=canting_e, + canting_n=canting_n, + device=device, + ) + + return surface_config diff --git a/artist/util/utils.py b/artist/util/utils.py index a68446f67..c61428f4f 100644 --- a/artist/util/utils.py +++ b/artist/util/utils.py @@ -1,7 +1,10 @@ -from typing import Union +from typing import TYPE_CHECKING, Union import torch +if TYPE_CHECKING: + from artist.field.kinematic_rigid_body import RigidBody + def batch_dot(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor: """ @@ -49,9 +52,10 @@ def rotate_distortions( torch.Tensor Corresponding rotation matrix. """ - assert ( - e.shape == u.shape - ), "The two tensors containing angles for the east and up rotation must have the same shape." + if e.shape != u.shape: + raise ValueError( + "The two tensors containing angles for the east and up rotation must have the same shape." + ) device = torch.device(device) cos_e = torch.cos(e) @@ -217,9 +221,11 @@ def translate_enu( torch.Tensor Corresponding rotation matrix. """ - assert ( - e.shape == u.shape == n.shape - ), "The three tensors containing the east, north, and up translations must have the same shape." + if not (e.shape == u.shape == n.shape): + raise ValueError( + "The three tensors containing the east, north, and up translations must have the same shape." + ) + device = torch.device(device) zeros = torch.zeros(e.shape, device=device) @@ -233,3 +239,473 @@ def translate_enu( torch.stack([zeros, zeros, zeros, ones]), ], ).squeeze(-1) + + +def azimuth_elevation_to_enu( + azimuth: torch.Tensor, + elevation: torch.Tensor, + slant_range: float = 1.0, + degree: bool = True, +) -> torch.Tensor: + """ + Coordinate transformation from azimuth and elevation to east, north, up. + + Parameters + ---------- + azimuth : torch.Tensor + Azimuth, clockwise from north (degrees). + elevation : torch.Tensor + Elevation angle above horizon, neglecting aberrations (degrees). + slant_range : float + Slant range (meters). + degree : bool + Whether input is given in degrees or radians. + + Returns + ------- + torch.Tensor + The east, north, up (enu) coordinates. + """ + if degree: + elevation = torch.deg2rad(elevation) + azimuth = torch.deg2rad(azimuth) + + r = slant_range * torch.cos(elevation) + + enu = torch.stack( + [ + r * torch.sin(azimuth), + -r * torch.cos(azimuth), + slant_range * torch.sin(elevation), + ], + dim=0, + ) + return enu + + +def convert_3d_point_to_4d_format( + point: torch.Tensor, device: Union[torch.device, str] = "cuda" +) -> torch.Tensor: + """ + Append ones to the last dimension of a 3D point vector. + + Includes the convention that points have a 1 and directions have 0 as 4th dimension. + + Parameters + ---------- + point : torch.Tensor + Input point in a 3D format. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + torch.Tensor + Point vector with ones appended at the last dimension. + """ + device = torch.device(device) + if point.size(dim=-1) != 3: + raise ValueError(f"Expected a 3D point but got a point of shape {point.shape}!") + + ones_tensor = torch.ones(point.shape[:-1] + (1,), dtype=point.dtype, device=device) + return torch.cat((point, ones_tensor), dim=-1) + + +def convert_3d_direction_to_4d_format( + direction: torch.Tensor, device: Union[torch.device, str] = "cuda" +) -> torch.Tensor: + """ + Append zeros to the last dimension of a 3D direction vector. + + Includes the convention that points have a 1 and directions have 0 as 4th dimension. + + Parameters + ---------- + direction : torch.Tensor + Input direction in a 3D format. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + torch.Tensor + Direction vector with ones appended at the last dimension. + """ + device = torch.device(device) + if direction.size(dim=-1) != 3: + raise ValueError( + f"Expected a 3D direction but got a direction of shape {direction.shape}!" + ) + + zeros_tensor = torch.zeros( + direction.shape[:-1] + (1,), dtype=direction.dtype, device=device + ) + return torch.cat((direction, zeros_tensor), dim=-1) + + +def convert_wgs84_coordinates_to_local_enu( + coordinates_to_transform: torch.Tensor, + reference_point: torch.Tensor, + device: Union[torch.device, str] = "cuda", +) -> torch.Tensor: + """ + Transform coordinates from latitude, longitude and altitude (WGS84) to local east, north, up (ENU). + + This function calculates the north and east offsets in meters of a coordinate from the reference point. + It converts the latitude and longitude to radians, calculates the radius of curvature values, + and then computes the offsets based on the differences between the coordinate and the refernce point. + Finally, it returns a tensor containing these offsets along with the altitude difference. + + Parameters + ---------- + coordinates_to_transform : torch.Tensor + The coordinates in latitude, longitude, altitude that are to be transformed. + reference_point : torch.Tensor + The center of origin of the ENU coordinate system in WGS84 coordinates. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + torch.Tensor + The east offset in meters, north offset in meters, and the altitude difference from the reference point. + """ + device = torch.device(device) + wgs84_a = 6378137.0 # Major axis in meters + wgs84_b = 6356752.314245 # Minor axis in meters + wgs84_e2 = (wgs84_a**2 - wgs84_b**2) / wgs84_a**2 # Eccentricity squared + + # Convert latitude and longitude to radians. + lat_rad = torch.deg2rad(coordinates_to_transform[0]) + lon_rad = torch.deg2rad(coordinates_to_transform[1]) + alt = coordinates_to_transform[2] - reference_point[2] + lat_tower_rad = torch.deg2rad(reference_point[0]) + lon_tower_rad = torch.deg2rad(reference_point[1]) + + # Calculate meridional radius of curvature for the first latitude. + sin_lat1 = torch.sin(lat_rad) + rn1 = wgs84_a / torch.sqrt(1 - wgs84_e2 * sin_lat1**2) + + # Calculate transverse radius of curvature for the first latitude. + rm1 = (wgs84_a * (1 - wgs84_e2)) / ((1 - wgs84_e2 * sin_lat1**2) ** 1.5) + + # Calculate delta latitude and delta longitude in radians. + dlat_rad = lat_tower_rad - lat_rad + dlon_rad = lon_tower_rad - lon_rad + + # Calculate north and east offsets in meters. + north_offset_m = dlat_rad * rm1 + east_offset_m = dlon_rad * rn1 * torch.cos(lat_rad) + + return torch.tensor( + [-east_offset_m, -north_offset_m, alt], dtype=torch.float32, device=device + ) + + +def get_rigid_body_kinematic_parameters_from_scenario( + kinematic: "RigidBody", +) -> list[torch.Tensor]: + """ + Extract all deviation parameters and actuator parameters from a rigid body kinematic. + + Parameters + ---------- + kinematic : RigidBody + The kinematic from which to extract the parameters. + + Returns + ------- + list[torch.Tensor] + The parameters from the kinematic (requires_grad is True). + """ + parameters_list = [ + kinematic.deviation_parameters.first_joint_translation_e, + kinematic.deviation_parameters.first_joint_translation_n, + kinematic.deviation_parameters.first_joint_translation_u, + kinematic.deviation_parameters.first_joint_tilt_e, + kinematic.deviation_parameters.first_joint_tilt_n, + kinematic.deviation_parameters.first_joint_tilt_u, + kinematic.deviation_parameters.second_joint_translation_e, + kinematic.deviation_parameters.second_joint_translation_n, + kinematic.deviation_parameters.second_joint_translation_u, + kinematic.deviation_parameters.second_joint_tilt_e, + kinematic.deviation_parameters.second_joint_tilt_n, + kinematic.deviation_parameters.second_joint_tilt_u, + kinematic.deviation_parameters.concentrator_translation_e, + kinematic.deviation_parameters.concentrator_translation_n, + kinematic.deviation_parameters.concentrator_translation_u, + kinematic.deviation_parameters.concentrator_tilt_e, + kinematic.deviation_parameters.concentrator_tilt_n, + kinematic.deviation_parameters.concentrator_tilt_u, + kinematic.actuators.actuator_list[0].increment, + kinematic.actuators.actuator_list[0].initial_stroke_length, + kinematic.actuators.actuator_list[0].offset, + kinematic.actuators.actuator_list[0].pivot_radius, + kinematic.actuators.actuator_list[0].initial_angle, + kinematic.actuators.actuator_list[1].increment, + kinematic.actuators.actuator_list[1].initial_stroke_length, + kinematic.actuators.actuator_list[1].offset, + kinematic.actuators.actuator_list[1].pivot_radius, + kinematic.actuators.actuator_list[1].initial_angle, + ] + for parameter in parameters_list: + if parameter is not None: + parameter.requires_grad_() + + return parameters_list + + +def normalize_points(points: torch.Tensor) -> torch.Tensor: + """ + Normalize points in a tensor to the open interval of (0,1). + + Parameters + ---------- + points : torch.Tensor + A tensor containing points to be normalized. + + Returns + ------- + torch.Tensor + The normalized points. + """ + # Since we want the open interval (0,1), a small offset is required to also exclude the boundaries. + points_normalized = (points[:] - min(points[:]) + 1e-5) / max( + (points[:] - min(points[:])) + 2e-5 + ) + return points_normalized + + +def corner_points_to_plane( + upper_left: torch.Tensor, + upper_right: torch.Tensor, + lower_left: torch.Tensor, + lower_right: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor]: + """ + Span a plane from corner points. + + Parameters + ---------- + upper_left : torch.Tensor + The upper left corner coordinate. + upper_right : torch.Tensor + The upper right corner coordinate. + lower_left : torch.Tensor + The lower left corner coordinate. + lower_right : torch.Tensor + The lower right corner coordinate. + + Returns + ------- + torch.Tensor + The plane measurement in east direction. + torch.Tensor + The plane measurement in up direction. + """ + plane_e = ( + torch.abs(upper_right[0] - upper_left[0]) + + torch.abs(lower_right[0] - lower_left[0]) + ) / 2 + plane_u = ( + torch.abs(upper_left[2] - lower_left[2]) + + torch.abs(upper_right[2] - lower_right[2]) + ) / 2 + return plane_e, plane_u + + +def decompose_rotation( + initial_vector: torch.Tensor, + target_vector: torch.Tensor, + device: Union[torch.device, str] = "cuda", +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Get the individual angles along the east-, north- and up-axis, to rotate and initial vector into a target vector. + + Parameters + ---------- + initial_vector : torch.Tensor + The initial vector. + rotated_vector : torch.Tensor + The rotated vector. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + torch.Tensor + The angle for the east-axis rotation. + torch.Tensor + The angle for the north-axis rotation. + torch.Tensor + The angle for the up-axis rotation. + + """ + device = torch.device(device) + # Normalize the input vectors + initial_vector = initial_vector / torch.linalg.norm(initial_vector) + target_vector = target_vector / torch.linalg.norm(target_vector) + + # Compute the cross product (rotation axis) + r = torch.linalg.cross(initial_vector, target_vector) + r_norm = torch.linalg.norm(r) + + # If the cross product is zero, the vectors are aligned; no rotation needed + if r_norm == 0: + return torch.tensor([0.0, 0.0, 0.0], device=device) + + # Normalize the rotation axis + r_normalized = r / r_norm + + # Compute the angle between the vectors + cos_theta = torch.clip(torch.dot(initial_vector, target_vector), -1.0, 1.0) + theta = torch.arccos(cos_theta) + + # Decompose the angle along each axis + theta_components = theta * r_normalized + + return theta_components[0], theta_components[1], theta_components[2] + + +def angle_between_vectors( + vector_1: torch.Tensor, vector_2: torch.Tensor +) -> torch.Tensor: + """ + Calculate the angle between two vectors. + + Parameters + ---------- + vector_1 : torch.Tensor + The first vector. + vector_2 : torch.Tensor + The second vector. + + Return + ------ + torch.Tensor + The angle between the input vectors. + """ + dot_product = torch.dot(vector_1, vector_2) + + norm_u = torch.norm(vector_1) + norm_v = torch.norm(vector_2) + + angle = dot_product / (norm_u * norm_v) + + angle = torch.clamp(angle, -1.0, 1.0) + + angle = torch.acos(angle) + + return angle + + +def transform_initial_angle( + initial_angle: torch.Tensor, + initial_orientation: torch.Tensor, + device: Union[torch.device, str] = "cuda", +) -> torch.Tensor: + """ + Compute the transformed angle of an initial angle in a rotated coordinate system. + + This function accounts for a known offset, the initial angle, in the + initial orientation vector. The offset represents a rotation around the + east-axis. When the coordinate system is rotated to align + the initial orientation with the ``ARTIST`` standard orientation, the axis for + the offset rotation also changes. This function calculates the equivalent + transformed angle for the offset in the rotated coordinate system. + + Parameters + ---------- + initial_angle : torch.Tensor + The initial angle, or offset along the east-axis. + initial_orientation : torch.Tensor + The initial orientation of the coordiante system. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + torch.Tensor + The transformed angle in the rotated coordinate system. + """ + device = torch.device(device) + # ``ARTIST`` is oriented towards the south ([0.0, -1.0, 0.0]) ENU. + artist_standard_orientation = torch.tensor([0.0, -1.0, 0.0, 0.0], device=device) + + # Apply the rotation by the initial angle to the initial orientation. + initial_orientation_with_offset = initial_orientation @ rotate_e( + e=initial_angle, + device=device, + ) + + # Compute the transformed angle relative to the reference orientation + transformed_initial_angle = angle_between_vectors( + initial_orientation[:-1], initial_orientation_with_offset[:-1] + ) - angle_between_vectors( + initial_orientation[:-1], artist_standard_orientation[:-1] + ) + + return transformed_initial_angle + + +def get_center_of_mass( + bitmap: torch.Tensor, + target_center: torch.Tensor, + plane_e: float, + plane_u: float, + threshold: float = 0.0, + device: Union[torch.device, str] = "cuda", +) -> torch.Tensor: + """ + Calculate the coordinates of the flux density center of mass. + + First determine the indices of the bitmap center of mass. + Next determine the position (coordinates) of the center of mass on the target. + + Parameters + ---------- + bitmap : torch.Tensor + The flux density in form of a bitmap. + target_center : torch.Tensor + The position of the center of the target. + plane_e : float + The width of the target surface. + plane_u : float + The height of the target surface. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Returns + ------- + torch.Tensor + The coordinates of the flux density center of mass. + """ + device = torch.device(device) + height, width = bitmap.shape + + # Threshold the bitmap values. Any values below the threshold are set to zero. + flux_thresholded = torch.where( + bitmap >= threshold, bitmap, torch.zeros_like(bitmap, device=device) + ) + total_intensity = flux_thresholded.sum() + + # Generate normalized east and up coordinates adjusted for pixel centers. + # The "+ 0.5" adjustment ensures coordinates are centered within each pixel. + e_indices = (torch.arange(width, dtype=torch.float32, device=device) + 0.5) / width + u_indices = ( + torch.arange(height, dtype=torch.float32, device=device) + 0.5 + ) / height + + # Compute the center of intensity using weighted sums of the coordinates. + center_of_mass_e = (flux_thresholded.sum(dim=0) * e_indices).sum() / total_intensity + center_of_mass_u = (flux_thresholded.sum(dim=1) * u_indices).sum() / total_intensity + + # Construct the coordinates relative to target center. + de = torch.tensor([plane_e, 0.0, 0.0, 0.0], device=device) + du = torch.tensor([0.0, 0.0, plane_u, 0.0], device=device) + + center_coordinates = ( + target_center - 0.5 * (de + du) + center_of_mass_e * de + center_of_mass_u * du + ) + + return center_coordinates diff --git a/pyproject.toml b/pyproject.toml index b8a97922c..a65c9a800 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dev = [ "ruff", "pytest", "pytest-cov", + "pytest-mock", "sphinx-autoapi", "sphinx-rtd-theme", "sphinxcontrib-napoleon", @@ -133,6 +134,5 @@ testpaths = [ [tool.coverage.run] omit = [ "artist/util/scenario_generator.py", - "artist/util/stral_to_surface_converter.py", "artist/util/configuration_classes.py" ] diff --git a/scenarios/example_generate_multiple_heliostat_scenario.py b/scenarios/example_generate_multiple_heliostat_scenario.py deleted file mode 100644 index 931d02a72..000000000 --- a/scenarios/example_generate_multiple_heliostat_scenario.py +++ /dev/null @@ -1,228 +0,0 @@ -import math -from pathlib import Path - -import torch - -from artist.util import config_dictionary -from artist.util.configuration_classes import ( - ActuatorConfig, - ActuatorParameters, - ActuatorPrototypeConfig, - FacetConfig, - HeliostatConfig, - HeliostatListConfig, - KinematicOffsets, - KinematicPrototypeConfig, - LightSourceConfig, - LightSourceListConfig, - PrototypeConfig, - ReceiverConfig, - ReceiverListConfig, - SurfacePrototypeConfig, -) -from artist.util.scenario_generator import ScenarioGenerator - -device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - -# The following parameter is the name of the scenario. -file_path = "multiple_heliostat_scenario" - -if not Path(file_path).parent.is_dir(): - raise FileNotFoundError( - f"The folder ``{Path(file_path).parent}`` selected to save the scenario does not exist. " - "Please create the folder or adjust the file path before running again!" - ) -# Include the receiver configuration. -receiver1_config = ReceiverConfig( - receiver_key="receiver1", - receiver_type=config_dictionary.receiver_type_planar, - position_center=torch.tensor([0.0, -50.0, 0.0, 1.0], device=device), - normal_vector=torch.tensor([0.0, 1.0, 0.0, 0.0], device=device), - plane_e=8.629666667, - plane_u=7.0, - resolution_e=256, - resolution_u=256, -) - -# Create list of receiver configs - in this case only one. -receiver_list = [receiver1_config] - -# Include the configuration for the list of receivers. -receiver_list_config = ReceiverListConfig(receiver_list=receiver_list) - -# Include the light source configuration. -light_source1_config = LightSourceConfig( - light_source_key="sun1", - light_source_type=config_dictionary.sun_key, - number_of_rays=10, - distribution_type=config_dictionary.light_source_distribution_is_normal, - mean=0.0, - covariance=4.3681e-06, -) - -# Create a list of light source configs - in this case only one. -light_source_list = [light_source1_config] - -# Include the configuration for the list of light sources. -light_source_list_config = LightSourceListConfig(light_source_list=light_source_list) - - -# Include four facets for the surface prototype. -prototype_facet1_config = FacetConfig( - facet_key="facet1", - control_points=torch.tensor([0.0, 0.0, 0.0, 0.0], device=device), - degree_e=2, - degree_n=2, - number_eval_points_e=10, - number_eval_points_n=10, - width=25.0, - height=25.0, - translation_vector=torch.tensor([0.0, 0.0, 0.0, 1.0], device=device), - canting_e=torch.tensor([0.0, 0.0, 0.0, 0.0], device=device), - canting_n=torch.tensor([0.0, 0.0, 0.0, 0.0], device=device), -) -prototype_facet2_config = FacetConfig( - facet_key="facet2", - control_points=torch.tensor([0.0, 0.0, 0.0, 0.0], device=device), - degree_e=2, - degree_n=2, - number_eval_points_e=10, - number_eval_points_n=10, - width=25.0, - height=25.0, - translation_vector=torch.tensor([0.0, 0.0, 0.0, 1.0], device=device), - canting_e=torch.tensor([0.0, 0.0, 0.0, 0.0], device=device), - canting_n=torch.tensor([0.0, 0.0, 0.0, 0.0], device=device), -) -prototype_facet3_config = FacetConfig( - facet_key="facet3", - control_points=torch.tensor([0.0, 0.0, 0.0, 0.0], device=device), - degree_e=2, - degree_n=2, - number_eval_points_e=10, - number_eval_points_n=10, - width=25.0, - height=25.0, - translation_vector=torch.tensor([0.0, 0.0, 0.0, 1.0], device=device), - canting_e=torch.tensor([0.0, 0.0, 0.0, 0.0], device=device), - canting_n=torch.tensor([0.0, 0.0, 0.0, 0.0], device=device), -) -prototype_facet4_config = FacetConfig( - facet_key="facet4", - control_points=torch.tensor([0.0, 0.0, 0.0, 0.0], device=device), - degree_e=2, - degree_n=2, - number_eval_points_e=10, - number_eval_points_n=10, - width=25.0, - height=25.0, - translation_vector=torch.tensor([0.0, 0.0, 0.0, 1.0], device=device), - canting_e=torch.tensor([0.0, 0.0, 0.0, 0.0], device=device), - canting_n=torch.tensor([0.0, 0.0, 0.0, 0.0], device=device), -) - -# Create a list of prototype facets. -prototype_facets_list = [ - prototype_facet1_config, - prototype_facet2_config, - prototype_facet3_config, - prototype_facet4_config, -] - -# Include the facet prototype config. -surface_prototype_config = SurfacePrototypeConfig(facets_list=prototype_facets_list) - -# Note, we do not include kinematic deviations in this scenario! - -# Include the initial orientation offsets for the kinematic. -kinematic_prototype_offsets = KinematicOffsets( - kinematic_initial_orientation_offset_e=torch.tensor(math.pi / 2, device=device) -) - -# Include the kinematic prototype configuration. -kinematic_prototype_config = KinematicPrototypeConfig( - kinematic_type=config_dictionary.rigid_body_key, - kinematic_initial_orientation_offsets=kinematic_prototype_offsets, -) - -# Include an ideal actuator. -actuator1_prototype = ActuatorConfig( - actuator_key="actuator1", - actuator_type=config_dictionary.ideal_actuator_key, - actuator_clockwise=False, -) - -# Include parameters for a linear actuator. -actuator2_prototype_parameters = ActuatorParameters( - increment=torch.tensor(0.0, device=device), - initial_stroke_length=torch.tensor(0.0, device=device), - offset=torch.tensor(0.0, device=device), - radius=torch.tensor(0.0, device=device), - phi_0=torch.tensor(0.0, device=device), -) - -# Include a linear actuator. -actuator2_prototype = ActuatorConfig( - actuator_key="actuator2", - actuator_type=config_dictionary.linear_actuator_key, - actuator_clockwise=True, - actuator_parameters=actuator2_prototype_parameters, -) - -# Create a list of actuators. -actuator_prototype_list = [actuator1_prototype, actuator2_prototype] - -# Include the actuator prototype config. -actuator_prototype_config = ActuatorPrototypeConfig( - actuator_list=actuator_prototype_list -) - -# Include the final prototype config. -prototype_config = PrototypeConfig( - surface_prototype=surface_prototype_config, - kinematic_prototype=kinematic_prototype_config, - actuator_prototype=actuator_prototype_config, -) - -# Note, we do not include individual heliostat parameters in this scenario. - -# Include the configuration for three heliostats. -heliostat1 = HeliostatConfig( - heliostat_key="heliostat1", - heliostat_id=1, - heliostat_position=torch.tensor([-50.0, 5.0, 0.0, 1.0], device=device), - heliostat_aim_point=torch.tensor([0.0, -50.0, 0.0, 1.0], device=device), -) -heliostat2 = HeliostatConfig( - heliostat_key="heliostat2", - heliostat_id=2, - heliostat_position=torch.tensor([25.0, 0.0, 0.0, 1.0], device=device), - heliostat_aim_point=torch.tensor([0.0, -50.0, 0.0, 1.0], device=device), -) -heliostat3 = HeliostatConfig( - heliostat_key="heliostat3", - heliostat_id=3, - heliostat_position=torch.tensor([50.0, 5.0, 0.0, 1.0], device=device), - heliostat_aim_point=torch.tensor([0.0, -50.0, 0.0, 1.0], device=device), -) - -# Create a list of all the heliostats. -heliostat_list = [heliostat1, heliostat2, heliostat3] - -# Create the configuration for all heliostats. -heliostats_list_config = HeliostatListConfig(heliostat_list=heliostat_list) - - -if __name__ == "__main__": - """Generate the scenario given the defined parameters.""" - # Create a scenario object. - scenario_object = ScenarioGenerator( - file_path=file_path, - receiver_list_config=receiver_list_config, - light_source_list_config=light_source_list_config, - prototype_config=prototype_config, - heliostat_list_config=heliostats_list_config, - ) - - # Generate the scenario. - scenario_object.generate_scenario() diff --git a/scenarios/multiple_heliostat_scenario.h5 b/scenarios/multiple_heliostat_scenario.h5 deleted file mode 100644 index 41468b6f3..000000000 Binary files a/scenarios/multiple_heliostat_scenario.h5 and /dev/null differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..4ce2e84bb --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +import pytest +import torch + + +@pytest.fixture(params=["cpu", "cuda:2"] if torch.cuda.is_available() else ["cpu"]) +def device(request: pytest.FixtureRequest) -> torch.device: + """ + Return the device on which to initialize tensors. + + Parameters + ---------- + request : pytest.FixtureRequest + The pytest fixture used to consider different test cases. + + Returns + ------- + torch.device + The device on which to initialize tensors. + """ + return torch.device(request.param) diff --git a/tests/data/calibration_properties.json b/tests/data/calibration_properties.json new file mode 100644 index 000000000..510719b70 --- /dev/null +++ b/tests/data/calibration_properties.json @@ -0,0 +1,21 @@ +{ + "motor_position": { + "axis_1_motor_position": 24282, + "axis_2_motor_position": 43957 + }, + "target_name": "multi_focus_tower", + "sun_elevation": 42.280163193653046, + "sun_azimuth": -53.786050074819535, + "focal_spot": { + "HeliOS": [ + 50.91339562237008, + 6.387563468702178, + 139.56155486890816 + ], + "UTIS": [ + 50.913395620694814, + 6.387563617390531, + 139.56536483764648 + ] + } +} diff --git a/tests/data/deflectometry.h5 b/tests/data/deflectometry.h5 new file mode 100644 index 000000000..ce737249d Binary files /dev/null and b/tests/data/deflectometry.h5 differ diff --git a/tests/field/test_bitmaps_load_surface_stral/individual_south_cpu.pt b/tests/data/expected_bitmaps_integration/individual_south_cpu.pt similarity index 84% rename from tests/field/test_bitmaps_load_surface_stral/individual_south_cpu.pt rename to tests/data/expected_bitmaps_integration/individual_south_cpu.pt index 6111ddac3..754bade12 100644 Binary files a/tests/field/test_bitmaps_load_surface_stral/individual_south_cpu.pt and b/tests/data/expected_bitmaps_integration/individual_south_cpu.pt differ diff --git a/tests/field/test_bitmaps_load_surface_stral/individual_south_gpu.pt b/tests/data/expected_bitmaps_integration/individual_south_cuda.pt similarity index 86% rename from tests/field/test_bitmaps_load_surface_stral/individual_south_gpu.pt rename to tests/data/expected_bitmaps_integration/individual_south_cuda.pt index 039cc52f5..e831f738d 100644 Binary files a/tests/field/test_bitmaps_load_surface_stral/individual_south_gpu.pt and b/tests/data/expected_bitmaps_integration/individual_south_cuda.pt differ diff --git a/tests/data/expected_bitmaps_integration/paint_south_cpu.pt b/tests/data/expected_bitmaps_integration/paint_south_cpu.pt new file mode 100644 index 000000000..52fe940d9 Binary files /dev/null and b/tests/data/expected_bitmaps_integration/paint_south_cpu.pt differ diff --git a/tests/data/expected_bitmaps_integration/paint_south_cuda.pt b/tests/data/expected_bitmaps_integration/paint_south_cuda.pt new file mode 100644 index 000000000..a65be7712 Binary files /dev/null and b/tests/data/expected_bitmaps_integration/paint_south_cuda.pt differ diff --git a/tests/field/test_bitmaps_load_surface_stral/above_cpu.pt b/tests/data/expected_bitmaps_integration/stral_above_cpu.pt similarity index 85% rename from tests/field/test_bitmaps_load_surface_stral/above_cpu.pt rename to tests/data/expected_bitmaps_integration/stral_above_cpu.pt index 7f214dd14..4c744848e 100644 Binary files a/tests/field/test_bitmaps_load_surface_stral/above_cpu.pt and b/tests/data/expected_bitmaps_integration/stral_above_cpu.pt differ diff --git a/tests/field/test_bitmaps_load_surface_stral/above_gpu.pt b/tests/data/expected_bitmaps_integration/stral_above_cuda.pt similarity index 84% rename from tests/field/test_bitmaps_load_surface_stral/above_gpu.pt rename to tests/data/expected_bitmaps_integration/stral_above_cuda.pt index fbc774cde..276859b5f 100644 Binary files a/tests/field/test_bitmaps_load_surface_stral/above_gpu.pt and b/tests/data/expected_bitmaps_integration/stral_above_cuda.pt differ diff --git a/tests/field/test_bitmaps_load_surface_stral/east_cpu.pt b/tests/data/expected_bitmaps_integration/stral_east_cpu.pt similarity index 87% rename from tests/field/test_bitmaps_load_surface_stral/east_cpu.pt rename to tests/data/expected_bitmaps_integration/stral_east_cpu.pt index 84d2a072b..4de4a498e 100644 Binary files a/tests/field/test_bitmaps_load_surface_stral/east_cpu.pt and b/tests/data/expected_bitmaps_integration/stral_east_cpu.pt differ diff --git a/tests/field/test_bitmaps_load_surface_stral/east_gpu.pt b/tests/data/expected_bitmaps_integration/stral_east_cuda.pt similarity index 85% rename from tests/field/test_bitmaps_load_surface_stral/east_gpu.pt rename to tests/data/expected_bitmaps_integration/stral_east_cuda.pt index efc3dcf73..5dc156848 100644 Binary files a/tests/field/test_bitmaps_load_surface_stral/east_gpu.pt and b/tests/data/expected_bitmaps_integration/stral_east_cuda.pt differ diff --git a/tests/field/test_bitmaps_load_surface_stral/south_cpu.pt b/tests/data/expected_bitmaps_integration/stral_south_cpu.pt similarity index 83% rename from tests/field/test_bitmaps_load_surface_stral/south_cpu.pt rename to tests/data/expected_bitmaps_integration/stral_south_cpu.pt index 0ac3248d7..6ab939ea7 100644 Binary files a/tests/field/test_bitmaps_load_surface_stral/south_cpu.pt and b/tests/data/expected_bitmaps_integration/stral_south_cpu.pt differ diff --git a/tests/field/test_bitmaps_load_surface_stral/south_gpu.pt b/tests/data/expected_bitmaps_integration/stral_south_cuda.pt similarity index 86% rename from tests/field/test_bitmaps_load_surface_stral/south_gpu.pt rename to tests/data/expected_bitmaps_integration/stral_south_cuda.pt index 27d822365..7282a33af 100644 Binary files a/tests/field/test_bitmaps_load_surface_stral/south_gpu.pt and b/tests/data/expected_bitmaps_integration/stral_south_cuda.pt differ diff --git a/tests/field/test_bitmaps_load_surface_stral/west_cpu.pt b/tests/data/expected_bitmaps_integration/stral_west_cpu.pt similarity index 86% rename from tests/field/test_bitmaps_load_surface_stral/west_cpu.pt rename to tests/data/expected_bitmaps_integration/stral_west_cpu.pt index e7681a6f0..51ddd58da 100644 Binary files a/tests/field/test_bitmaps_load_surface_stral/west_cpu.pt and b/tests/data/expected_bitmaps_integration/stral_west_cpu.pt differ diff --git a/tests/field/test_bitmaps_load_surface_stral/west_gpu.pt b/tests/data/expected_bitmaps_integration/stral_west_cuda.pt similarity index 86% rename from tests/field/test_bitmaps_load_surface_stral/west_gpu.pt rename to tests/data/expected_bitmaps_integration/stral_west_cuda.pt index b8c5a22a7..2dd10ea67 100644 Binary files a/tests/field/test_bitmaps_load_surface_stral/west_gpu.pt and b/tests/data/expected_bitmaps_integration/stral_west_cuda.pt differ diff --git a/tests/data/expected_optimized_alignment_parameters/use_motor_positions_cpu.pt b/tests/data/expected_optimized_alignment_parameters/use_motor_positions_cpu.pt new file mode 100644 index 000000000..94321d6c7 Binary files /dev/null and b/tests/data/expected_optimized_alignment_parameters/use_motor_positions_cpu.pt differ diff --git a/tests/data/expected_optimized_alignment_parameters/use_motor_positions_cuda.pt b/tests/data/expected_optimized_alignment_parameters/use_motor_positions_cuda.pt new file mode 100644 index 000000000..b2900d0fe Binary files /dev/null and b/tests/data/expected_optimized_alignment_parameters/use_motor_positions_cuda.pt differ diff --git a/tests/data/expected_optimized_alignment_parameters/use_raytracing_cpu.pt b/tests/data/expected_optimized_alignment_parameters/use_raytracing_cpu.pt new file mode 100644 index 000000000..d613c4166 Binary files /dev/null and b/tests/data/expected_optimized_alignment_parameters/use_raytracing_cpu.pt differ diff --git a/tests/data/expected_optimized_alignment_parameters/use_raytracing_cuda.pt b/tests/data/expected_optimized_alignment_parameters/use_raytracing_cuda.pt new file mode 100644 index 000000000..b8375c809 Binary files /dev/null and b/tests/data/expected_optimized_alignment_parameters/use_raytracing_cuda.pt differ diff --git a/tests/data/heliostat_properties.json b/tests/data/heliostat_properties.json new file mode 100644 index 000000000..493733126 --- /dev/null +++ b/tests/data/heliostat_properties.json @@ -0,0 +1,134 @@ +{ + "heliostat_position": [ + 50.913644729255935, + 6.387991189938079, + 88.795 + ], + "height": 2.559999942779541, + "width": 3.2200000286102295, + "initial_orientation": [ + 0.0, + 0.0, + 1.0 + ], + "kinematic_properties": { + "actuators": [ + { + "type_axis": "linear", + "min_increment": 0, + "max_increment": 68745, + "increment": 154166.66666666666, + "offset_shift": 0, + "initial_stroke_length": 0.07742006040729, + "offset": 0.335308, + "pivot_radius": 0.338095, + "radius_shift": 0, + "clockwise_axis_movement": 0, + "initial_angle": 0.0389695375867206, + "min_movement_angle": 0.0151232779026031, + "max_movement_angle": 1.5707963267948966, + "movement_speed": 0.025 + }, + { + "type_axis": "linear", + "min_increment": 0, + "max_increment": 75308, + "increment": 154166.66666666666, + "offset_shift": 0, + "initial_stroke_length": 0.0774436632489418, + "offset": 0.340771, + "pivot_radius": 0.3191, + "radius_shift": 0, + "clockwise_axis_movement": 1, + "initial_angle": 0.943870007981347, + "min_movement_angle": -0.95993, + "max_movement_angle": 0.925674074325306, + "movement_speed": 0.025 + } + ], + "joint_translation_e_1": 0.0, + "joint_translation_n_1": 0.0, + "joint_translation_u_1": 0.0, + "joint_translation_e_2": 0.0, + "joint_translation_n_2": 0.0, + "joint_translation_u_2": 0.3149999976158142, + "concentrator_translation_e": 0.0, + "concentrator_translation_n": -0.17755000293254852, + "concentrator_translation_u": -0.40450000762939453 + }, + "facet_properties": { + "canting_type": "receiver canting", + "number_of_facets": 4, + "facets": [ + { + "translation_vector": [ + -0.8075, + 0.6425, + 0.0402 + ], + "canting_e": [ + -0.8024845719337463, + 0.0, + -0.004984567873179913 + ], + "canting_n": [ + -1.9569211872294545e-05, + -0.6374921798706055, + 0.0031505227088928223 + ] + }, + { + "translation_vector": [ + 0.8075, + 0.6425, + 0.0402 + ], + "canting_e": [ + -0.8024845719337463, + 0.0, + 0.004984567873179913 + ], + "canting_n": [ + 1.9569211872294545e-05, + -0.6374921798706055, + 0.0031505227088928223 + ] + }, + { + "translation_vector": [ + -0.8075, + -0.6425, + 0.0402 + ], + "canting_e": [ + -0.8024845719337463, + 0.0, + -0.004984567873179913 + ], + "canting_n": [ + 1.9569211872294545e-05, + -0.6374921798706055, + -0.0031505227088928223 + ] + }, + { + "translation_vector": [ + 0.8075, + -0.6425, + 0.0402 + ], + "canting_e": [ + -0.8024845719337463, + 0.0, + 0.004984567873179913 + ], + "canting_n": [ + -1.9569211872294545e-05, + -0.6374921798706055, + -0.0031505227088928223 + ] + } + ] + }, + "renovation": "2021-04-15" +} diff --git a/tests/data/scenario_generators/generate_scenario_for_test_alignment_optimizer.py b/tests/data/scenario_generators/generate_scenario_for_test_alignment_optimizer.py new file mode 100644 index 000000000..e435a0252 --- /dev/null +++ b/tests/data/scenario_generators/generate_scenario_for_test_alignment_optimizer.py @@ -0,0 +1,158 @@ +import pathlib + +import torch + +from artist import ARTIST_ROOT +from artist.util import config_dictionary, paint_loader, set_logger_config +from artist.util.configuration_classes import ( + ActuatorPrototypeConfig, + HeliostatConfig, + HeliostatListConfig, + KinematicPrototypeConfig, + LightSourceConfig, + LightSourceListConfig, + PowerPlantConfig, + PrototypeConfig, + ReceiverConfig, + ReceiverListConfig, + SurfacePrototypeConfig, +) +from artist.util.scenario_generator import ScenarioGenerator +from artist.util.surface_converter import SurfaceConverter + +# Set up logger +set_logger_config() + +torch.manual_seed(7) +torch.cuda.manual_seed(7) + +device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + +# The following parameter is the name of the scenario. +file_path = pathlib.Path(ARTIST_ROOT) / "tests/data/test_scenario_paint" + +if not pathlib.Path(file_path).parent.is_dir(): + raise FileNotFoundError( + f"The folder ``{pathlib.Path(file_path).parent}`` selected to save the scenario does not exist." + "Please create the folder or adjust the file path before running again!" + ) + +tower_file = pathlib.Path(ARTIST_ROOT) / "tests/data/tower.json" +calibration_file = pathlib.Path(ARTIST_ROOT) / "tests/data/calibration_properties.json" +heliostat_file = pathlib.Path(ARTIST_ROOT) / "tests/data/heliostat_properties.json" +deflectometry_file = pathlib.Path(ARTIST_ROOT) / "tests/data/deflectometry.h5" + + +calibration_target_name = paint_loader.extract_paint_calibration_target_name( + calibration_file +) + +power_plant_position, target_type, target_center, normal_vector, plane_e, plane_u = ( + paint_loader.extract_paint_tower_measurements( + tower_file, calibration_target_name, device + ) +) + +heliostat_position, kinematic_config, actuators_list_config = ( + paint_loader.extract_paint_heliostat_properties( + heliostat_file, power_plant_position, device + ) +) + +# Include the power plant configuration. +power_plant_config = PowerPlantConfig(power_plant_position=power_plant_position) + +# Include the receiver configuration. +receiver1_config = ReceiverConfig( + receiver_key="receiver_1", + receiver_type=target_type, + position_center=target_center, + normal_vector=normal_vector, + plane_e=plane_e, + plane_u=plane_u, + resolution_e=256, + resolution_u=256, +) + +# Create list of receiver configs - in this case only one. +receiver_list = [receiver1_config] + +# Include the configuration for the list of receivers. +receiver_list_config = ReceiverListConfig(receiver_list=receiver_list) + +# Include the light source configuration. +light_source1_config = LightSourceConfig( + light_source_key="sun_1", + light_source_type=config_dictionary.sun_key, + number_of_rays=1, + distribution_type=config_dictionary.light_source_distribution_is_normal, + mean=0.0, + covariance=4.3681e-06, +) + +# Create a list of light source configs - in this case only one. +light_source_list = [light_source1_config] + +# Include the configuration for the list of light sources. +light_source_list_config = LightSourceListConfig(light_source_list=light_source_list) + + +# Generate surface configuration from STRAL data. +surface_converter = SurfaceConverter( + step_size=100, + max_epoch=400, +) + +facet_prototype_list = surface_converter.generate_surface_config_from_paint( + deflectometry_file_path=deflectometry_file, + heliostat_file_path=heliostat_file, + device=device, +) + +surface_prototype_config = SurfacePrototypeConfig(facets_list=facet_prototype_list) + +# Include the kinematic prototype configuration. +kinematic_prototype_config = KinematicPrototypeConfig( + type=config_dictionary.rigid_body_key, + initial_orientation=kinematic_config.initial_orientation, + deviations=kinematic_config.deviations, +) + +# Include the actuator prototype config. +actuator_prototype_config = ActuatorPrototypeConfig( + actuator_list=actuators_list_config.actuator_list +) + +# Include the final prototype config. +prototype_config = PrototypeConfig( + surface_prototype=surface_prototype_config, + kinematic_prototype=kinematic_prototype_config, + actuator_prototype=actuator_prototype_config, +) + +# Include the configuration for a heliostat. +heliostat1 = HeliostatConfig( + heliostat_key="heliostat_1", + heliostat_id=1, + heliostat_position=heliostat_position, + heliostat_aim_point=target_center, +) + +# Create a list of all the heliostats - in this case, only one. +heliostat_list = [heliostat1] + +# Create the configuration for all heliostats. +heliostats_list_config = HeliostatListConfig(heliostat_list=heliostat_list) + + +if __name__ == "__main__": + """Generate the scenario given the defined parameters.""" + scenario_generator = ScenarioGenerator( + file_path=file_path, + power_plant_config=power_plant_config, + receiver_list_config=receiver_list_config, + light_source_list_config=light_source_list_config, + prototype_config=prototype_config, + heliostat_list_config=heliostats_list_config, + ) + scenario_generator.generate_scenario() diff --git a/scenarios/example_generate_test_scenario.py b/tests/data/scenario_generators/generate_scenario_for_test_integration_stral.py similarity index 68% rename from scenarios/example_generate_test_scenario.py rename to tests/data/scenario_generators/generate_scenario_for_test_integration_stral.py index 58eedf7d4..a94e3ec9b 100644 --- a/scenarios/example_generate_test_scenario.py +++ b/tests/data/scenario_generators/generate_scenario_for_test_integration_stral.py @@ -1,41 +1,53 @@ -import math -from pathlib import Path +import pathlib import torch from artist import ARTIST_ROOT -from artist.util import config_dictionary +from artist.util import config_dictionary, set_logger_config from artist.util.configuration_classes import ( ActuatorConfig, ActuatorPrototypeConfig, HeliostatConfig, HeliostatListConfig, - KinematicOffsets, KinematicPrototypeConfig, LightSourceConfig, LightSourceListConfig, + PowerPlantConfig, PrototypeConfig, ReceiverConfig, ReceiverListConfig, SurfacePrototypeConfig, ) from artist.util.scenario_generator import ScenarioGenerator -from artist.util.stral_to_surface_converter import StralToSurfaceConverter +from artist.util.surface_converter import SurfaceConverter + +# Set up logger +set_logger_config() + +torch.manual_seed(7) +torch.cuda.manual_seed(7) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # The following parameter is the name of the scenario. -file_path = "test_scenario" +file_path = pathlib.Path(ARTIST_ROOT) / "tests/data/test_scenario_stral" -if not Path(file_path).parent.is_dir(): +if not pathlib.Path(file_path).parent.is_dir(): raise FileNotFoundError( - f"The folder ``{Path(file_path).parent}`` selected to save the scenario does not exist. " + f"The folder ``{pathlib.Path(file_path).parent}`` selected to save the scenario does not exist. " "Please create the folder or adjust the file path before running again!" ) +stral_file_path = pathlib.Path(ARTIST_ROOT) / "tests/data/stral_test_data.binp" + +# Include the power plant configuration. +power_plant_config = PowerPlantConfig( + power_plant_position=torch.tensor([0.0, 0.0, 0.0], device=device) +) + # Include the receiver configuration. receiver1_config = ReceiverConfig( - receiver_key="receiver1", + receiver_key="receiver_1", receiver_type=config_dictionary.receiver_type_planar, position_center=torch.tensor([0.0, -50.0, 0.0, 1.0], device=device), normal_vector=torch.tensor([0.0, 1.0, 0.0, 0.0], device=device), @@ -53,7 +65,7 @@ # Include the light source configuration. light_source1_config = LightSourceConfig( - light_source_key="sun1", + light_source_key="sun_1", light_source_type=config_dictionary.sun_key, number_of_rays=10, distribution_type=config_dictionary.light_source_distribution_is_normal, @@ -67,56 +79,38 @@ # Include the configuration for the list of light sources. light_source_list_config = LightSourceListConfig(light_source_list=light_source_list) - # Generate surface configuration from STRAL data. -stral_converter = StralToSurfaceConverter( - stral_file_path=f"{ARTIST_ROOT}/measurement_data/stral_test_data", - surface_header_name="=5f2I2f", - facet_header_name="=i9fI", - points_on_facet_struct_name="=7f", +surface_converter = SurfaceConverter( step_size=100, + max_epoch=400, ) -facet_prototype_list = stral_converter.generate_surface_config_from_stral( - number_eval_points_e=100, - number_eval_points_n=100, - conversion_method=config_dictionary.convert_nurbs_from_normals, - number_control_points_e=20, - number_control_points_n=20, - degree_e=3, - degree_n=3, - tolerance=3e-5, - max_epoch=10000, - initial_learning_rate=1e-3, - device=device, + +facet_prototype_list = surface_converter.generate_surface_config_from_stral( + stral_file_path=stral_file_path, device=device ) +# Generate the surface prototype configuration. surface_prototype_config = SurfacePrototypeConfig(facets_list=facet_prototype_list) # Note, we do not include kinematic deviations in this scenario! - -# Include the initial orientation offsets for the kinematic. -kinematic_prototype_offsets = KinematicOffsets( - kinematic_initial_orientation_offset_e=torch.tensor(math.pi / 2, device=device) -) - # Include the kinematic prototype configuration. kinematic_prototype_config = KinematicPrototypeConfig( - kinematic_type=config_dictionary.rigid_body_key, - kinematic_initial_orientation_offsets=kinematic_prototype_offsets, + type=config_dictionary.rigid_body_key, + initial_orientation=[0.0, 0.0, 1.0, 0.0], ) # Include an ideal actuator. actuator1_prototype = ActuatorConfig( - actuator_key="actuator1", - actuator_type=config_dictionary.ideal_actuator_key, - actuator_clockwise=False, + key="actuator_1", + type=config_dictionary.ideal_actuator_key, + clockwise_axis_movement=False, ) # Include a linear actuator. actuator2_prototype = ActuatorConfig( - actuator_key="actuator2", - actuator_type=config_dictionary.ideal_actuator_key, - actuator_clockwise=True, + key="actuator_2", + type=config_dictionary.ideal_actuator_key, + clockwise_axis_movement=True, ) # Create a list of actuators. @@ -153,14 +147,12 @@ if __name__ == "__main__": """Generate the scenario given the defined parameters.""" - # Create a scenario object. - scenario_object = ScenarioGenerator( + scenario_generator = ScenarioGenerator( file_path=file_path, + power_plant_config=power_plant_config, receiver_list_config=receiver_list_config, light_source_list_config=light_source_list_config, prototype_config=prototype_config, heliostat_list_config=heliostats_list_config, ) - - # Generate the scenario. - scenario_object.generate_scenario() + scenario_generator.generate_scenario() diff --git a/scenarios/example_generate_individual_test_scenario.py b/tests/data/scenario_generators/generate_scenario_for_test_integration_stral_individual.py similarity index 63% rename from scenarios/example_generate_individual_test_scenario.py rename to tests/data/scenario_generators/generate_scenario_for_test_integration_stral_individual.py index 68dd2097f..8c621126f 100644 --- a/scenarios/example_generate_individual_test_scenario.py +++ b/tests/data/scenario_generators/generate_scenario_for_test_integration_stral_individual.py @@ -1,10 +1,9 @@ -import math -from pathlib import Path +import pathlib import torch from artist import ARTIST_ROOT -from artist.util import config_dictionary +from artist.util import config_dictionary, set_logger_config from artist.util.configuration_classes import ( ActuatorConfig, ActuatorListConfig, @@ -12,10 +11,10 @@ HeliostatConfig, HeliostatListConfig, KinematicConfig, - KinematicOffsets, KinematicPrototypeConfig, LightSourceConfig, LightSourceListConfig, + PowerPlantConfig, PrototypeConfig, ReceiverConfig, ReceiverListConfig, @@ -23,22 +22,37 @@ SurfacePrototypeConfig, ) from artist.util.scenario_generator import ScenarioGenerator -from artist.util.stral_to_surface_converter import StralToSurfaceConverter +from artist.util.surface_converter import SurfaceConverter + +# Set up logger +set_logger_config() + +torch.manual_seed(7) +torch.cuda.manual_seed(7) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # The following parameter is the name of the scenario. -file_path = "test_individual_measurements_scenario" +file_path = file_path = ( + pathlib.Path(ARTIST_ROOT) / "tests/data/test_scenario_stral_individual_measurements" +) -if not Path(file_path).parent.is_dir(): +if not pathlib.Path(file_path).parent.is_dir(): raise FileNotFoundError( - f"The folder ``{Path(file_path).parent}`` selected to save the scenario does not exist. " + f"The folder ``{pathlib.Path(file_path).parent}`` selected to save the scenario does not exist. " "Please create the folder or adjust the file path before running again!" ) +stral_file_path = pathlib.Path(ARTIST_ROOT) / "tests/data/stral_test_data.binp" + +# Include the power plant configuration. +power_plant_config = PowerPlantConfig( + power_plant_position=torch.tensor([0.0, 0.0, 0.0], device=device) +) + # Include the receiver configuration. receiver1_config = ReceiverConfig( - receiver_key="receiver1", + receiver_key="receiver_1", receiver_type=config_dictionary.receiver_type_planar, position_center=torch.tensor([0.0, -50.0, 0.0, 1.0], device=device), normal_vector=torch.tensor([0.0, 1.0, 0.0, 0.0], device=device), @@ -56,7 +70,7 @@ # Include the light source configuration. light_source1_config = LightSourceConfig( - light_source_key="sun1", + light_source_key="sun_1", light_source_type=config_dictionary.sun_key, number_of_rays=10, distribution_type=config_dictionary.light_source_distribution_is_normal, @@ -71,52 +85,37 @@ light_source_list_config = LightSourceListConfig(light_source_list=light_source_list) # Generate surface configuration from STRAL data. -stral_converter = StralToSurfaceConverter( - stral_file_path=f"{ARTIST_ROOT}/measurement_data/stral_test_data", - surface_header_name="=5f2I2f", - facet_header_name="=i9fI", - points_on_facet_struct_name="=7f", +surface_converter = SurfaceConverter( step_size=100, + max_epoch=400, ) -list_of_facets = stral_converter.generate_surface_config_from_stral( - number_eval_points_e=100, - number_eval_points_n=100, - conversion_method=config_dictionary.convert_nurbs_from_normals, - number_control_points_e=20, - number_control_points_n=20, - degree_e=3, - degree_n=3, - tolerance=3e-5, - max_epoch=10000, - initial_learning_rate=1e-3, - device=device, + +facets_list = surface_converter.generate_surface_config_from_stral( + stral_file_path=stral_file_path, device=device ) -surface_prototype_config = SurfacePrototypeConfig(facets_list=list_of_facets) +surface_prototype_config = SurfacePrototypeConfig(facets_list=facets_list) # Include the initial orientation offsets for the kinematic. -kinematic_prototype_offsets = KinematicOffsets( - kinematic_initial_orientation_offset_e=torch.tensor(math.pi / 2, device=device) -) # Include the kinematic prototype configuration. kinematic_prototype_config = KinematicPrototypeConfig( - kinematic_type=config_dictionary.rigid_body_key, - kinematic_initial_orientation_offsets=kinematic_prototype_offsets, + type=config_dictionary.rigid_body_key, + initial_orientation=[0.0, 0.0, 1.0, 0.0], ) # Include an ideal actuator. actuator1_prototype = ActuatorConfig( - actuator_key="actuator1", - actuator_type=config_dictionary.ideal_actuator_key, - actuator_clockwise=False, + key="actuator_1", + type=config_dictionary.ideal_actuator_key, + clockwise_axis_movement=False, ) # Include a linear actuator. actuator2_prototype = ActuatorConfig( - actuator_key="actuator2", - actuator_type=config_dictionary.ideal_actuator_key, - actuator_clockwise=True, + key="actuator_2", + type=config_dictionary.ideal_actuator_key, + clockwise_axis_movement=True, ) # Create a list of actuators. @@ -135,37 +134,32 @@ ) # Include the heliostat surface config. In this case, it is identical to the prototype. -heliostat1_surface_config = SurfaceConfig(facets_list=list_of_facets) +heliostat1_surface_config = SurfaceConfig(facets_list=facets_list) # Include kinematic configuration for the heliostat. -heliostat1_kinematic_offsets = KinematicOffsets( - kinematic_initial_orientation_offset_e=torch.tensor(math.pi / 2, device=device), -) heliostat1_kinematic_config = KinematicConfig( - kinematic_type=config_dictionary.rigid_body_key, - kinematic_initial_orientation_offsets=heliostat1_kinematic_offsets, + type=config_dictionary.rigid_body_key, + initial_orientation=[0.0, 0.0, 1.0, 0.0], ) # Include actuators for the heliostat. -# Include an ideal actuator. actuator1_heliostat1 = ActuatorConfig( - actuator_key="actuator1", - actuator_type=config_dictionary.ideal_actuator_key, - actuator_clockwise=False, + key="actuator_1", + type=config_dictionary.ideal_actuator_key, + clockwise_axis_movement=False, ) - -# Include a linear actuator. actuator2_heliostat1 = ActuatorConfig( - actuator_key="actuator2", - actuator_type=config_dictionary.ideal_actuator_key, - actuator_clockwise=True, + key="actuator_2", + type=config_dictionary.ideal_actuator_key, + clockwise_axis_movement=True, ) + actuator_heliostat1_list = [actuator1_heliostat1, actuator2_heliostat1] heliostat1_actuator_config = ActuatorListConfig(actuator_list=actuator_heliostat1_list) # Include the configuration for a heliostat. heliostat_1 = HeliostatConfig( - heliostat_key="heliostat1", + heliostat_key="heliostat_1", heliostat_id=1, heliostat_position=torch.tensor([0.0, 5.0, 0.0, 1.0], device=device), heliostat_aim_point=torch.tensor([0.0, -50.0, 0.0, 1.0], device=device), @@ -182,14 +176,12 @@ if __name__ == "__main__": """Generate the scenario given the defined parameters.""" - # Create a scenario object. - scenario_object = ScenarioGenerator( + scenario_generator = ScenarioGenerator( file_path=file_path, + power_plant_config=power_plant_config, receiver_list_config=receiver_list_config, light_source_list_config=light_source_list_config, prototype_config=prototype_config, heliostat_list_config=heliostats_list_config, ) - - # Generate the scenario. - scenario_object.generate_scenario() + scenario_generator.generate_scenario() diff --git a/measurement_data/stral_test_data.binp b/tests/data/stral_test_data.binp similarity index 100% rename from measurement_data/stral_test_data.binp rename to tests/data/stral_test_data.binp diff --git a/tests/data/test_scenario_paint.h5 b/tests/data/test_scenario_paint.h5 new file mode 100644 index 000000000..c7621fd4d Binary files /dev/null and b/tests/data/test_scenario_paint.h5 differ diff --git a/scenarios/test_scenario.h5 b/tests/data/test_scenario_stral.h5 similarity index 60% rename from scenarios/test_scenario.h5 rename to tests/data/test_scenario_stral.h5 index c03344091..18b056aeb 100644 Binary files a/scenarios/test_scenario.h5 and b/tests/data/test_scenario_stral.h5 differ diff --git a/scenarios/test_individual_measurements_scenario.h5 b/tests/data/test_scenario_stral_individual_measurements.h5 similarity index 55% rename from scenarios/test_individual_measurements_scenario.h5 rename to tests/data/test_scenario_stral_individual_measurements.h5 index b08d7035f..b123a9722 100644 Binary files a/scenarios/test_individual_measurements_scenario.h5 and b/tests/data/test_scenario_stral_individual_measurements.h5 differ diff --git a/tests/data/tower.json b/tests/data/tower.json new file mode 100644 index 000000000..98cfc096f --- /dev/null +++ b/tests/data/tower.json @@ -0,0 +1,180 @@ +{ + "power_plant_properties": { + "ID": "WRI1030197", + "coordinates": [ + 50.91342112259258, + 6.387824755874856, + 87.0 + ] + }, + "solar_tower_juelich_upper": { + "type": "planar", + "normal_vector": [ + 0, + 1, + 0 + ], + "coordinates": { + "center": [ + 50.91339203683997, + 6.387824563513243, + 130.09766666666667 + ], + "upper_left": [ + 50.91339196507306, + 6.387885982262168, + 133.684 + ], + "upper_middle": [ + 50.91339190867827, + 6.387824583774972, + 133.71 + ], + "upper_right": [ + 50.91339211259599, + 6.387763286988281, + 133.719 + ], + "lower_left": [ + 50.91339186595943, + 6.387886052532388, + 126.476 + ], + "lower_right": [ + 50.91339215692524, + 6.387763472205384, + 126.506 + ] + } + }, + "solar_tower_juelich_lower": { + "type": "planar", + "normal_vector": [ + 0, + 1, + 0 + ], + "coordinates": { + "center": [ + 50.91339203683997, + 6.387824563513243, + 122.8815 + ], + "upper_left": [ + 50.91339186595943, + 6.387886052532388, + 126.476 + ], + "upper_right": [ + 50.91339215692524, + 6.387763472205384, + 126.506 + ], + "lower_left": [ + 50.913391839040266, + 6.38788603808917, + 119.268 + ], + "lower_middle": [ + 50.91339210657432, + 6.387824542765122, + 119.269 + ], + "lower_right": [ + 50.9133923375531, + 6.387763217765237, + 119.279 + ] + } + }, + "multi_focus_tower": { + "type": "planar", + "normal_vector": [ + 0, + 1, + 0 + ], + "coordinates": { + "center": [ + 50.91339645088695, + 6.387574436728054, + 138.97975 + ], + "upper_left": [ + 50.91339628900999, + 6.387612983329584, + 142.175 + ], + "upper_right": [ + 50.913396616772935, + 6.387536032350528, + 142.172 + ], + "lower_left": [ + 50.91339634341573, + 6.387612841591359, + 135.789 + ], + "lower_right": [ + 50.91339655432385, + 6.3875358896401675, + 135.783 + ] + } + }, + "receiver": { + "type": "convex_cylinder", + "normal_vector": [ + 0, + 1, + 0 + ], + "coordinates": { + "center": [ + 50.91341660151, + 6.387825304776098, + 142.22674999999998 + ], + "receiver_outer_upper_left": [ + 50.91342727218299, + 6.387856856914401, + 144.805 + ], + "receiver_outer_upper_right": [ + 50.91342773925188, + 6.387792121250146, + 144.82 + ], + "receiver_outer_lower_left": [ + 50.91340547556243, + 6.3878562915348525, + 139.596 + ], + "receiver_outer_lower_right": [ + 50.91340570660374, + 6.3877922506716125, + 139.592 + ], + "receiver_inner_lower_left": [ + 50.913406544144294, + 6.387853925842858, + 139.86 + ], + "receiver_inner_lower_right": [ + 50.91340664929648, + 6.387795301404112, + 139.862 + ], + "receiver_inner_upper_left": [ + 50.91342645401072, + 6.387854205350705, + 144.592 + ], + "receiver_inner_upper_right": [ + 50.913426766473705, + 6.387795411983428, + 144.593 + ] + } + } +} diff --git a/tests/field/test_actuator_array.py b/tests/field/test_actuator_array.py new file mode 100644 index 000000000..6847a5992 --- /dev/null +++ b/tests/field/test_actuator_array.py @@ -0,0 +1,63 @@ +import pytest +import torch + +from artist.field.actuator_array import ActuatorArray +from artist.util import config_dictionary +from artist.util.configuration_classes import ActuatorConfig, ActuatorListConfig + + +@pytest.fixture( + params=[ + config_dictionary.ideal_actuator_key, + config_dictionary.linear_actuator_key, + "invalid", + ] +) +def actuator_config(request: pytest.FixtureRequest) -> ActuatorConfig: + """ + Define an actuator config. + + Parameters + ---------- + request : pytest.FixtureRequest + The pytest fixture used to consider different test cases. + + Returns + ------- + ActuatorConfig + An actuator config. + """ + return ActuatorConfig( + key="actuator_1", + type=request.param, + clockwise_axis_movement=False, + ) + + +def test_actuator_array_errors( + actuator_config: ActuatorConfig, + device: torch.device, +) -> None: + """ + Test that actuator array raises errors with improper initialization. + + Parameters + ---------- + actuator_config : ActuatorConfig + The actuator config with a specific actuator type. + device : torch.device + The device on which to initialize tensors (default is cuda). + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + actuator_list_config = ActuatorListConfig([actuator_config]) + if actuator_config.type == "invalid": + with pytest.raises(KeyError) as exc_info: + ActuatorArray(actuator_list_config, device=device) + assert ( + f"Currently the selected actuator type: {actuator_config.type} is not supported." + in str(exc_info.value) + ) diff --git a/tests/field/test_integration_stral_data.py b/tests/field/test_integration_alignment.py similarity index 60% rename from tests/field/test_integration_stral_data.py rename to tests/field/test_integration_alignment.py index 8229111ed..6e2333c6b 100644 --- a/tests/field/test_integration_stral_data.py +++ b/tests/field/test_integration_alignment.py @@ -33,46 +33,29 @@ rank = 0 -@pytest.fixture(params=["cpu", "cuda"] if torch.cuda.is_available() else ["cpu"]) -def device(request: pytest.FixtureRequest) -> torch.device: - """ - Return the device on which to initialize tensors. - - Parameters - ---------- - request : pytest.FixtureRequest - The pytest fixture used to consider different test cases. - - Returns - ------- - torch.device - The device on which to initialize tensors. - """ - return torch.device(request.param) - - @pytest.mark.parametrize( - "incident_ray_direction, expected_value, scenario_config", + "incident_ray_direction, ray_direction_string, scenario_config", [ - (torch.tensor([0.0, -1.0, 0.0, 0.0]), "south", "test_scenario"), - (torch.tensor([1.0, 0.0, 0.0, 0.0]), "east", "test_scenario"), - (torch.tensor([-1.0, 0.0, 0.0, 0.0]), "west", "test_scenario"), - (torch.tensor([0.0, 0.0, 1.0, 0.0]), "above", "test_scenario"), + (torch.tensor([0.0, -1.0, 0.0, 0.0]), "stral_south", "test_scenario_stral"), + (torch.tensor([1.0, 0.0, 0.0, 0.0]), "stral_east", "test_scenario_stral"), + (torch.tensor([-1.0, 0.0, 0.0, 0.0]), "stral_west", "test_scenario_stral"), + (torch.tensor([0.0, 0.0, 1.0, 0.0]), "stral_above", "test_scenario_stral"), ( torch.tensor([0.0, -1.0, 0.0, 0.0]), "individual_south", - "test_individual_measurements_scenario", - ), # Test if loading with individual measurements works + "test_scenario_stral_individual_measurements", + ), + (torch.tensor([0.0, -1.0, 0.0, 0.0]), "paint_south", "test_scenario_paint"), ], ) -def test_compute_bitmaps( +def test_integration_alignment( incident_ray_direction: torch.Tensor, - expected_value: str, + ray_direction_string: str, scenario_config: str, device: torch.device, ) -> None: """ - Compute the resulting flux density distribution (bitmap) for the given test case. + Align helisotats from different scenarios using the kinematic module to test the alignment process. With the aligned surface and the light direction, reflect the rays at every normal on the heliostat surface to calculate the preferred reflection direction. @@ -84,8 +67,8 @@ def test_compute_bitmaps( ---------- incident_ray_direction : torch.Tensor The incident ray direction used for the test. - expected_value : str - The path to the expected value bitmap. + ray_direction_string : str + String value describing the ray direction. scenario_config : str The name of the scenario to be loaded. device : torch.device @@ -100,13 +83,17 @@ def test_compute_bitmaps( torch.cuda.manual_seed(7) # Load the scenario. - with h5py.File(f"{ARTIST_ROOT}/scenarios/{scenario_config}.h5", "r") as config_h5: + with h5py.File( + pathlib.Path(ARTIST_ROOT) / "tests/data" / f"{scenario_config}.h5", "r" + ) as config_h5: scenario = Scenario.load_scenario_from_hdf5( scenario_file=config_h5, device=device ) # Align heliostat. - scenario.heliostats.heliostat_list[0].set_aligned_surface( + scenario.heliostats.heliostat_list[ + 0 + ].set_aligned_surface_with_incident_ray_direction( incident_ray_direction=incident_ray_direction.to(device), device=device ) @@ -126,17 +113,10 @@ def test_compute_bitmaps( final_bitmap = raytracer.normalize_bitmap(final_bitmap) if rank == 0: - if device.type == "cpu": - expected_path = ( - pathlib.Path(ARTIST_ROOT) - / "tests/field/test_bitmaps_load_surface_stral" - / f"{expected_value}_cpu.pt" - ) - else: - expected_path = ( - pathlib.Path(ARTIST_ROOT) - / "tests/field/test_bitmaps_load_surface_stral" - / f"{expected_value}_gpu.pt" - ) - expected = torch.load(expected_path).to(device) + expected_path = ( + pathlib.Path(ARTIST_ROOT) + / "tests/data/expected_bitmaps_integration" + / f"{ray_direction_string}_{device.type}.pt" + ) + expected = torch.load(expected_path, map_location=device, weights_only=True) torch.testing.assert_close(final_bitmap.T, expected, atol=5e-4, rtol=5e-4) diff --git a/tests/field/test_linear_actuator.py b/tests/field/test_linear_actuator.py index 97ebf77eb..d4856db16 100644 --- a/tests/field/test_linear_actuator.py +++ b/tests/field/test_linear_actuator.py @@ -13,24 +13,6 @@ ) -@pytest.fixture(params=["cpu", "cuda"] if torch.cuda.is_available() else ["cpu"]) -def device(request: pytest.FixtureRequest) -> torch.device: - """ - Return the device on which to initialize tensors. - - Parameters - ---------- - request : pytest.FixtureRequest - The pytest fixture used to consider different test cases. - - Returns - ------- - torch.device - The device on which to initialize tensors. - """ - return torch.device(request.param) - - @pytest.fixture def deviation_parameters(device: torch.device) -> KinematicDeviations: """ @@ -88,28 +70,28 @@ def actuator_configuration(device: torch.device) -> ActuatorListConfig: increment=torch.tensor(154166.666, device=device), initial_stroke_length=torch.tensor(0.075, device=device), offset=torch.tensor(0.34061, device=device), - radius=torch.tensor(0.3204, device=device), - phi_0=torch.tensor(-1.570796, device=device), + pivot_radius=torch.tensor(0.3204, device=device), + initial_angle=torch.tensor(-1.570796, device=device), ) actuator2_parameters = ActuatorParameters( increment=torch.tensor(154166.666, device=device), initial_stroke_length=torch.tensor(0.075, device=device), offset=torch.tensor(0.3479, device=device), - radius=torch.tensor(0.309, device=device), - phi_0=torch.tensor(0.959931, device=device), + pivot_radius=torch.tensor(0.309, device=device), + initial_angle=torch.tensor(0.959931, device=device), ) actuator1_config = ActuatorConfig( - actuator_key="", - actuator_type=config_dictionary.linear_actuator_key, - actuator_clockwise=False, - actuator_parameters=actuator1_parameters, + key="", + type=config_dictionary.linear_actuator_key, + clockwise_axis_movement=False, + parameters=actuator1_parameters, ) actuator2_config = ActuatorConfig( - actuator_key="", - actuator_type=config_dictionary.linear_actuator_key, - actuator_clockwise=True, - actuator_parameters=actuator2_parameters, + key="", + type=config_dictionary.linear_actuator_key, + clockwise_axis_movement=True, + parameters=actuator2_parameters, ) return ActuatorListConfig(actuator_list=[actuator1_config, actuator2_config]) @@ -144,6 +126,7 @@ def kinematic_model_1( position=position, aim_point=aim_point, actuator_config=actuator_configuration, + initial_orientation=torch.tensor([0.0, -1.0, 0.0, 0.0], device=device), deviation_parameters=deviation_parameters, device=device, ) @@ -178,6 +161,7 @@ def kinematic_model_2( position=position, aim_point=aim_point, actuator_config=actuator_configuration, + initial_orientation=torch.tensor([0.0, -1.0, 0.0, 0.0], device=device), deviation_parameters=deviation_parameters, device=device, ) @@ -288,9 +272,11 @@ def test_orientation_matrix( AssertionError If test does not complete as expected. """ - orientation_matrix = request.getfixturevalue(kinematic_model_fixture).align( + orientation_matrix = request.getfixturevalue( + kinematic_model_fixture + ).incident_ray_direction_to_orientation( incident_ray_direction.to(device), device=device ) torch.testing.assert_close( - orientation_matrix[0], expected.to(device), atol=5e-4, rtol=5e-4 + orientation_matrix, expected.to(device), atol=5e-4, rtol=5e-4 ) diff --git a/tests/field/test_load_heliostat.py b/tests/field/test_load_heliostat.py new file mode 100644 index 000000000..710d4dbe6 --- /dev/null +++ b/tests/field/test_load_heliostat.py @@ -0,0 +1,87 @@ +from typing import Optional +from unittest.mock import MagicMock + +import h5py +import pytest +import torch +from pytest_mock import MockerFixture + +from artist.field.heliostat import Heliostat + + +@pytest.fixture +def prototype_mock_generator(mocker: MockerFixture) -> MagicMock: + """ + Generate a mock prototype. + + Parameters + ---------- + mocker : MockerFixture + A pytest-mock fixture used to create mock objects. + + Returns + ------- + MagicMock + A mock prototype. + """ + return mocker.MagicMock() + + +@pytest.mark.parametrize( + "prototype_surface, prototype_kinematic, error", + [ + ( + None, + None, + "If the heliostat does not have individual surface parameters, a surface prototype must be provided!", + ), + ( + prototype_mock_generator, + None, + "If the heliostat does not have an individual kinematic, a kinematic prototype must be provided!", + ), + ( + prototype_mock_generator, + prototype_mock_generator, + "If the heliostat does not have individual actuators, an actuator prototype must be provided!", + ), + ], +) +def test_heliostat_load_from_hdf5_errors( + mocker: MockerFixture, + prototype_surface: Optional[MagicMock], + prototype_kinematic: Optional[MagicMock], + error: str, + device: torch.device, +) -> None: + """ + Test the heliostat load from hdf5 method. + + Parameters + ---------- + mocker : MockerFixture + A pytest-mocker fixture used to create mock objects. + prototype_surface : Optional[MagicMock] + The mock prototype surface. + prototype_kinematic : Optional[MagicMock] + The mock prototype kinematic. + error : str + The expected error message. + device : torch.device + The device on which to initialize tensors. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + mock_h5_file = mocker.MagicMock(spec=h5py.File) + + with pytest.raises(ValueError) as exc_info: + Heliostat.from_hdf5( + config_file=mock_h5_file, + prototype_surface=prototype_surface, + prototype_kinematic=prototype_kinematic, + device=device, + ) + assert error in str(exc_info.value) diff --git a/tests/field/test_only_nurbs.py b/tests/field/test_only_nurbs.py index 313ccb15a..46fde4775 100644 --- a/tests/field/test_only_nurbs.py +++ b/tests/field/test_only_nurbs.py @@ -1,27 +1,8 @@ -import pytest import torch from artist.util.nurbs import NURBSSurface -@pytest.fixture(params=["cpu", "cuda"] if torch.cuda.is_available() else ["cpu"]) -def device(request: pytest.FixtureRequest) -> torch.device: - """ - Return the device on which to initialize tensors. - - Parameters - ---------- - request : pytest.FixtureRequest - The pytest fixture used to consider different test cases. - - Returns - ------- - torch.device - The device on which to initialize tensors. - """ - return torch.device(request.param) - - def random_surface( e: torch.Tensor, n: torch.Tensor, diff --git a/tests/field/test_rigid_body.py b/tests/field/test_rigid_body.py index 47663e269..f40990610 100644 --- a/tests/field/test_rigid_body.py +++ b/tests/field/test_rigid_body.py @@ -1,5 +1,3 @@ -import math - import pytest import torch @@ -10,30 +8,29 @@ from artist.util.configuration_classes import ( ActuatorConfig, ActuatorListConfig, - KinematicOffsets, ) -@pytest.fixture(params=["cpu", "cuda"] if torch.cuda.is_available() else ["cpu"]) -def device(request: pytest.FixtureRequest) -> torch.device: +@pytest.fixture +def actuator_configuration_1() -> ActuatorListConfig: """ - Return the device on which to initialize tensors. - - Parameters - ---------- - request : pytest.FixtureRequest - The pytest fixture used to consider different test cases. + Define actuator parameters used in tests. Returns ------- - torch.device - The device on which to initialize tensors. + ActuatorListConfig + A List containing parameters for each actuator. """ - return torch.device(request.param) + actuator1_config = ActuatorConfig( + key="", + type=config_dictionary.ideal_actuator_key, + clockwise_axis_movement=False, + ) + return ActuatorListConfig(actuator_list=[actuator1_config]) @pytest.fixture -def actuator_configuration() -> ActuatorListConfig: +def actuator_configuration_2() -> ActuatorListConfig: """ Define actuator parameters used in tests. @@ -43,22 +40,52 @@ def actuator_configuration() -> ActuatorListConfig: A List containing parameters for each actuator. """ actuator1_config = ActuatorConfig( - actuator_key="", - actuator_type=config_dictionary.ideal_actuator_key, - actuator_clockwise=False, + key="", + type=config_dictionary.ideal_actuator_key, + clockwise_axis_movement=False, ) actuator2_config = ActuatorConfig( - actuator_key="", - actuator_type=config_dictionary.ideal_actuator_key, - actuator_clockwise=True, + key="", + type=config_dictionary.ideal_actuator_key, + clockwise_axis_movement=True, ) return ActuatorListConfig(actuator_list=[actuator1_config, actuator2_config]) @pytest.fixture -def initial_offsets_south(device: torch.device) -> KinematicOffsets: +def actuator_configuration_3() -> ActuatorListConfig: + """ + Define actuator parameters used in tests. + + Returns + ------- + ActuatorListConfig + A List containing parameters for each actuator. + """ + actuator1_config = ActuatorConfig( + key="", + type=config_dictionary.ideal_actuator_key, + clockwise_axis_movement=False, + ) + actuator2_config = ActuatorConfig( + key="", + type=config_dictionary.ideal_actuator_key, + clockwise_axis_movement=True, + ) + actuator3_config = ActuatorConfig( + key="", + type=config_dictionary.ideal_actuator_key, + clockwise_axis_movement=True, + ) + return ActuatorListConfig( + actuator_list=[actuator1_config, actuator2_config, actuator3_config] + ) + + +@pytest.fixture +def initial_orientation_south(device: torch.device) -> torch.Tensor: """ - Define initial offsets for a south-orientated heliostat. + Define initial orientation vector for a south-orientated heliostat. Parameters ---------- @@ -67,21 +94,17 @@ def initial_offsets_south(device: torch.device) -> KinematicOffsets: Returns ------- - KinematicOffsets - Initial kinematic offsets for a south-orientated heliostat. + torch.Tensor + Initial kinematic orientation vector for a south-orientated heliostat. """ - initial_offsets = KinematicOffsets( - kinematic_initial_orientation_offset_e=torch.tensor(0.0, device=device), - kinematic_initial_orientation_offset_n=torch.tensor(0.0, device=device), - kinematic_initial_orientation_offset_u=torch.tensor(0.0, device=device), - ) - return initial_offsets + initial_orientation_south = torch.tensor([0.0, -1.0, 0.0, 0.0], device=device) + return initial_orientation_south @pytest.fixture -def initial_offsets_above(device: torch.device) -> KinematicOffsets: +def initial_orientation_up(device: torch.device) -> torch.Tensor: """ - Define initial offsets for an up-orientated heliostat. + Define initial orientation vector for an up-orientated heliostat. Parameters ---------- @@ -90,21 +113,17 @@ def initial_offsets_above(device: torch.device) -> KinematicOffsets: Returns ------- - KinematicOffsets - Initial kinematic offsets for an up-orientated heliostat. + torch.Tensor + Initial kinematic orientation vector for an up-orientated heliostat. """ - initial_offsets = KinematicOffsets( - kinematic_initial_orientation_offset_e=torch.tensor(math.pi / 2, device=device), - kinematic_initial_orientation_offset_n=torch.tensor(0.0, device=device), - kinematic_initial_orientation_offset_u=torch.tensor(0.0, device=device), - ) - return initial_offsets + initial_orientation_up = torch.tensor([0.0, 0.0, 1.0, 0.0], device=device) + return initial_orientation_up @pytest.fixture def kinematic_model_1( - actuator_configuration: ActuatorListConfig, - initial_offsets_south: KinematicOffsets, + actuator_configuration_2: ActuatorListConfig, + initial_orientation_south: torch.Tensor, device: torch.device, ) -> RigidBody: """ @@ -112,10 +131,10 @@ def kinematic_model_1( Parameters ---------- - actuator_configuration : ActuatorListConfig + actuator_configuration_2 : ActuatorListConfig The configuration of the actuators. - initial_offsets_south : KinematicOffsets - The kinematic initial orientation offsets. + initial_orientation_south : torch.Tensor + The kinematic initial orientation. device : torch.device The device on which to initialize tensors. @@ -129,16 +148,16 @@ def kinematic_model_1( return RigidBody( position=position, aim_point=aim_point, - actuator_config=actuator_configuration, - initial_orientation_offsets=initial_offsets_south, + actuator_config=actuator_configuration_2, + initial_orientation=initial_orientation_south, device=device, ) @pytest.fixture def kinematic_model_2( - actuator_configuration: ActuatorListConfig, - initial_offsets_south: KinematicOffsets, + actuator_configuration_2: ActuatorListConfig, + initial_orientation_south: torch.Tensor, device: torch.device, ) -> RigidBody: """ @@ -146,10 +165,10 @@ def kinematic_model_2( Parameters ---------- - actuator_configuration : ActuatorListConfig + actuator_configuration_2 : ActuatorListConfig The configuration of the actuators. - initial_offsets_south : KinematicOffsets - The kinematic initial orientation offsets. + initial_orientation_south : torch.Tensor + The kinematic initial orientation. device : torch.device The device on which to initialize tensors. @@ -163,16 +182,84 @@ def kinematic_model_2( return RigidBody( position=position, aim_point=aim_point, - actuator_config=actuator_configuration, - initial_orientation_offsets=initial_offsets_south, + actuator_config=actuator_configuration_2, + initial_orientation=initial_orientation_south, device=device, ) @pytest.fixture def kinematic_model_3( - actuator_configuration: ActuatorListConfig, - initial_offsets_above: KinematicOffsets, + actuator_configuration_2: ActuatorListConfig, + initial_orientation_up: torch.Tensor, + device: torch.device, +) -> RigidBody: + """ + Create a kinematic model to use in the test. + + Parameters + ---------- + actuator_configuration_2 : ActuatorListConfig + The configuration of the actuators. + initial_orientation_up : torch.Tensor + The kinematic initial orientation offsets. + device : torch.device + The device on which to initialize tensors. + + Returns + ------- + RigidBody + The kinematic model. + """ + position = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) + aim_point = torch.tensor([0.0, -10.0, 0.0, 1.0], device=device) + return RigidBody( + position=position, + aim_point=aim_point, + actuator_config=actuator_configuration_2, + initial_orientation=initial_orientation_up, + device=device, + ) + + +@pytest.fixture +def kinematic_model_4( + actuator_configuration_1: ActuatorListConfig, + initial_orientation_up: torch.Tensor, + device: torch.device, +) -> RigidBody: + """ + Create a kinematic model to use in the test. + + Parameters + ---------- + actuator_configuration_1 : ActuatorListConfig + The configuration of the actuators. + initial_orientation_up : torch.Tensor + The kinematic initial orientation offsets. + device : torch.device + The device on which to initialize tensors. + + Returns + ------- + RigidBody + The kinematic model. + """ + position = torch.tensor([0.0, 0.0, 0.0, 1.0], device=device) + aim_point = torch.tensor([0.0, -10.0, 0.0, 1.0], device=device) + return RigidBody( + position=position, + aim_point=aim_point, + actuator_config=actuator_configuration_1, + initial_orientation=initial_orientation_up, + device=device, + ) + + +@pytest.fixture +def kinematic_model_5( + actuator_configuration_3: ActuatorListConfig, + initial_orientation_up: torch.Tensor, device: torch.device, ) -> RigidBody: """ @@ -180,9 +267,9 @@ def kinematic_model_3( Parameters ---------- - actuator_configuration : ActuatorListConfig + actuator_configuration_3 : ActuatorListConfig The configuration of the actuators. - initial_offsets_above : KinematicOffsets + initial_orientation_up : torch.Tensor The kinematic initial orientation offsets. device : torch.device The device on which to initialize tensors. @@ -197,23 +284,34 @@ def kinematic_model_3( return RigidBody( position=position, aim_point=aim_point, - actuator_config=actuator_configuration, - initial_orientation_offsets=initial_offsets_above, + actuator_config=actuator_configuration_3, + initial_orientation=initial_orientation_up, device=device, ) @pytest.mark.parametrize( - "kinematic_model_fixture, incident_ray_direction, expected", + "kinematic_model_fixture, input, method, expected", [ ( "kinematic_model_1", torch.tensor([0.0, 0.0, 1.0, 0.0]), + "incident_ray_direction_to_orientation", torch.tensor( [ [1, 0, 0, 0], - [0, math.cos(-math.pi / 4), -math.sin(-math.pi / 4), 0], - [0, math.sin(-math.pi / 4), math.cos(-math.pi / 4), 0], + [ + 0, + torch.cos(torch.tensor(-torch.pi / 4)), + -torch.sin(torch.tensor(-torch.pi / 4)), + 0, + ], + [ + 0, + torch.sin(torch.tensor(-torch.pi / 4)), + torch.cos(torch.tensor(-torch.pi / 4)), + 0, + ], [0, 0, 0, 1], ] ), @@ -221,10 +319,21 @@ def kinematic_model_3( ( "kinematic_model_1", torch.tensor([1.0, 0.0, 0.0, 0.0]), + "incident_ray_direction_to_orientation", torch.tensor( [ - [math.cos(math.pi / 4), -math.sin(math.pi / 4), 0.0, 0.0], - [math.sin(math.pi / 4), math.cos(math.pi / 4), 0.0, 0.0], + [ + torch.cos(torch.tensor(torch.pi / 4)), + -torch.sin(torch.tensor(torch.pi / 4)), + 0.0, + 0.0, + ], + [ + torch.sin(torch.tensor(torch.pi / 4)), + torch.cos(torch.tensor(torch.pi / 4)), + 0.0, + 0.0, + ], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], ] @@ -233,6 +342,7 @@ def kinematic_model_3( ( "kinematic_model_1", torch.tensor([0.0, -1.0, 0.0, 0.0]), + "incident_ray_direction_to_orientation", torch.tensor( [ [1.0, 0.0, 0.0, 0.0], @@ -245,10 +355,21 @@ def kinematic_model_3( ( "kinematic_model_1", torch.tensor([-1.0, 0.0, 0.0, 0.0]), + "incident_ray_direction_to_orientation", torch.tensor( [ - [math.cos(-math.pi / 4), -math.sin(-math.pi / 4), 0.0, 0.0], - [math.sin(-math.pi / 4), math.cos(-math.pi / 4), 0.0, 0.0], + [ + torch.cos(torch.tensor(-torch.pi / 4)), + -torch.sin(torch.tensor(-torch.pi / 4)), + 0.0, + 0.0, + ], + [ + torch.sin(torch.tensor(-torch.pi / 4)), + torch.cos(torch.tensor(-torch.pi / 4)), + 0.0, + 0.0, + ], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0], ] @@ -257,11 +378,22 @@ def kinematic_model_3( ( "kinematic_model_1", torch.tensor([0.0, -1.0, 1.0, 0.0]), + "incident_ray_direction_to_orientation", torch.tensor( [ [1.0, 0.0, 0.0, 0.0], - [0.0, math.cos(-math.pi / 8), -math.sin(-math.pi / 8), 0.0], - [0.0, math.sin(-math.pi / 8), math.cos(-math.pi / 8), 0.0], + [ + 0.0, + torch.cos(torch.tensor(-torch.pi / 8)), + -torch.sin(torch.tensor(-torch.pi / 8)), + 0.0, + ], + [ + 0.0, + torch.sin(torch.tensor(-torch.pi / 8)), + torch.cos(torch.tensor(-torch.pi / 8)), + 0.0, + ], [0.0, 0.0, 0.0, 1.0], ] ), @@ -269,11 +401,22 @@ def kinematic_model_3( ( "kinematic_model_2", torch.tensor([0.0, 0.0, 1.0, 0.0]), + "incident_ray_direction_to_orientation", torch.tensor( [ [1.0, 0.0, 0.0, 0.0], - [0.0, math.cos(-math.pi / 4), -math.sin(-math.pi / 4), 1.0], - [0.0, math.sin(-math.pi / 4), math.cos(-math.pi / 4), 0.0], + [ + 0.0, + torch.cos(torch.tensor(-torch.pi / 4)), + -torch.sin(torch.tensor(-torch.pi / 4)), + 1.0, + ], + [ + 0.0, + torch.sin(torch.tensor(-torch.pi / 4)), + torch.cos(torch.tensor(-torch.pi / 4)), + 0.0, + ], [0.0, 0.0, 0.0, 1.0], ] ), @@ -281,11 +424,22 @@ def kinematic_model_3( ( "kinematic_model_3", torch.tensor([0.0, 0.0, 1.0, 0.0]), + "incident_ray_direction_to_orientation", torch.tensor( [ [1.0, 0.0, 0.0, 0.0], - [0.0, math.cos(math.pi / 4), -math.sin(math.pi / 4), 0.0], - [0.0, math.sin(math.pi / 4), math.cos(math.pi / 4), 0.0], + [ + 0.0, + torch.cos(torch.tensor(torch.pi / 4)), + -torch.sin(torch.tensor(torch.pi / 4)), + 0.0, + ], + [ + 0.0, + torch.sin(torch.tensor(torch.pi / 4)), + torch.cos(torch.tensor(torch.pi / 4)), + 0.0, + ], [0.0, 0.0, 0.0, 1.0], ] ), @@ -293,11 +447,22 @@ def kinematic_model_3( ( "kinematic_model_3", torch.tensor([0.0, -1.0, 0.0, 0.0]), + "incident_ray_direction_to_orientation", torch.tensor( [ [1.0, 0.0, 0.0, 0.0], - [0.0, math.cos(math.pi / 2), -math.sin(math.pi / 2), 0.0], - [0.0, math.sin(math.pi / 2), math.cos(math.pi / 2), 0.0], + [ + 0.0, + torch.cos(torch.tensor(torch.pi / 2)), + -torch.sin(torch.tensor(torch.pi / 2)), + 0.0, + ], + [ + 0.0, + torch.sin(torch.tensor(torch.pi / 2)), + torch.cos(torch.tensor(torch.pi / 2)), + 0.0, + ], [0.0, 0.0, 0.0, 1.0], ] ), @@ -305,10 +470,21 @@ def kinematic_model_3( ( "kinematic_model_3", torch.tensor([1.0, 0.0, 0.0, 0.0]), + "incident_ray_direction_to_orientation", torch.tensor( [ - [math.cos(math.pi / 4), 0.0, math.sin(math.pi / 4), 0.0], - [math.sin(math.pi / 4), 0.0, -math.cos(math.pi / 4), 0.0], + [ + torch.cos(torch.tensor(torch.pi / 4)), + 0.0, + torch.sin(torch.tensor(torch.pi / 4)), + 0.0, + ], + [ + torch.sin(torch.tensor(torch.pi / 4)), + 0.0, + -torch.cos(torch.tensor(torch.pi / 4)), + 0.0, + ], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], ] @@ -317,21 +493,96 @@ def kinematic_model_3( ( "kinematic_model_3", torch.tensor([-1.0, 0.0, 0.0, 0.0]), + "incident_ray_direction_to_orientation", torch.tensor( [ - [math.cos(-math.pi / 4), 0.0, math.sin(-math.pi / 4), 0.0], - [math.sin(-math.pi / 4), 0.0, -math.cos(-math.pi / 4), 0.0], + [ + torch.cos(torch.tensor(-torch.pi / 4)), + 0.0, + torch.sin(torch.tensor(-torch.pi / 4)), + 0.0, + ], + [ + torch.sin(torch.tensor(-torch.pi / 4)), + 0.0, + -torch.cos(torch.tensor(-torch.pi / 4)), + 0.0, + ], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], ] ), ), + ( + "kinematic_model_4", + torch.tensor([-1.0, 0.0, 0.0, 0.0]), + "incident_ray_direction_to_orientation", + None, + ), + ( + "kinematic_model_5", + torch.tensor([-1.0, 0.0, 0.0, 0.0]), + "incident_ray_direction_to_orientation", + None, + ), + ( + "kinematic_model_1", + torch.tensor([0.0, 0.0]), + "motor_positions_to_orientation", + torch.tensor( + [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ] + ), + ), + ( + "kinematic_model_2", + torch.tensor([5.0, 1000.0]), + "motor_positions_to_orientation", + torch.tensor( + [ + [0.562379062176, -0.826879560947, 0.000000000000, 0.000000000000], + [0.234554469585, 0.159525677562, 0.958924293518, 1.000000000000], + [-0.792914927006, -0.539278924465, 0.283662199974, 0.000000000000], + [0.000000000000, 0.000000000000, 0.000000000000, 1.000000000000], + ] + ), + ), + ( + "kinematic_model_3", + torch.tensor([10.0, 40.0]), + "motor_positions_to_orientation", + torch.tensor( + [ + [-6.6694e-01, 3.2570e-08, 7.4511e-01, 0.0000e00], + [-6.2520e-01, 5.4402e-01, -5.5961e-01, 0.0000e00], + [-4.0536e-01, -8.3907e-01, -3.6283e-01, 0.0000e00], + [0.0000e00, 0.0000e00, 0.0000e00, 1.0000e00], + ] + ), + ), + ( + "kinematic_model_4", + torch.tensor([0.0, 0.0]), + "motor_positions_to_orientation", + None, + ), + ( + "kinematic_model_5", + torch.tensor([0.0, 0.0]), + "motor_positions_to_orientation", + None, + ), ], ) def test_orientation_matrix( request: pytest.FixtureRequest, kinematic_model_fixture: str, - incident_ray_direction: torch.Tensor, + input: torch.Tensor, + method, expected: torch.Tensor, device: torch.device, ) -> None: @@ -344,10 +595,12 @@ def test_orientation_matrix( The pytest fixture used to consider different test cases. kinematic_model_fixture : str The kinematic model fixture used to select the kinematic model used in the test case. - incident_ray_direction : torch.Tensor - The incident ray direction considered. + input : torch.Tensor + + method + expected : torch.Tensor - The expected orientation matrix. + The expected orientation matrix or ``None`` if an error is expected. device : torch.device The device on which to initialize tensors. @@ -356,7 +609,16 @@ def test_orientation_matrix( AssertionError If test does not complete as expected. """ - orientation_matrix = request.getfixturevalue(kinematic_model_fixture).align( - incident_ray_direction.to(device), device=device - ) - torch.testing.assert_close(orientation_matrix[0], expected.to(device)) + # Check if the ValueError is thrown as expected. + get_orientation = getattr(request.getfixturevalue(kinematic_model_fixture), method) + if expected is None: + with pytest.raises(ValueError) as exc_info: + get_orientation(input.to(device), device=device) + assert ( + f"The rigid body kinematic requires exactly two actuators but {len(request.getfixturevalue(kinematic_model_fixture).actuators.actuator_list)} were specified, please check the configuration!" + in str(exc_info.value) + ) + else: + # Check if the orientation matrix is correct. + orientation_matrix = get_orientation(input.to(device), device=device) + torch.testing.assert_close(orientation_matrix, expected.to(device)) diff --git a/tests/field/test_set_preferred_reflection_direction.py b/tests/field/test_set_preferred_reflection_direction.py new file mode 100644 index 000000000..2661b968c --- /dev/null +++ b/tests/field/test_set_preferred_reflection_direction.py @@ -0,0 +1,35 @@ +import pytest +from pytest_mock import MockerFixture + +from artist.field.heliostat import Heliostat + + +def test_set_preferred_reflection_direction_error( + mocker: MockerFixture, +) -> None: + """ + Test setting the preferred reflection direction for unaligned heliostats. + + Parameters + ---------- + mocker : MockerFixture + A pytest-mock fixture used to create mock objects. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + mock_heliostat = mocker.MagicMock(spec=Heliostat) + mock_heliostat.is_aligned = False + mock_rays = mocker.MagicMock() + + mock_heliostat.set_preferred_reflection_direction = ( + Heliostat.set_preferred_reflection_direction.__get__(mock_heliostat, Heliostat) + ) + + with pytest.raises(ValueError) as exc_info: + mock_heliostat.set_preferred_reflection_direction( + rays=mock_rays, + ) + assert "Heliostat has not yet been aligned." in str(exc_info.value) diff --git a/tests/raytracing/test_line_plane_intersection.py b/tests/raytracing/test_line_plane_intersection.py index b325859c3..77bbeeb0e 100644 --- a/tests/raytracing/test_line_plane_intersection.py +++ b/tests/raytracing/test_line_plane_intersection.py @@ -6,24 +6,6 @@ from artist.raytracing import raytracing_utils -@pytest.fixture(params=["cpu", "cuda"] if torch.cuda.is_available() else ["cpu"]) -def device(request: pytest.FixtureRequest) -> torch.device: - """ - Return the device on which to initialize tensors. - - Parameters - ---------- - request : pytest.FixtureRequest - The pytest fixture used to consider different test cases. - - Returns - ------- - torch.device - The device on which to initialize tensors. - """ - return torch.device(request.param) - - @pytest.mark.parametrize( "ray_directions, plane_normal_vectors, plane_center, points_at_ray_origin, expected_intersections", [ @@ -61,14 +43,14 @@ def device(request: pytest.FixtureRequest) -> torch.device: [[0.0, 0.0, 0.0, 1.0], [1.0, 1.0, 0.0, 1.0], [0.0, -2.0, 0.0, 1.0]] ), ), - ( # AssertionError - no intersection since ray is parallel to plane. + ( # ValueError - no intersection since ray is parallel to plane. torch.tensor([1.0, 0.0, 0.0, 0.0]), torch.tensor([0.0, 0.0, 1.0, 0.0]), torch.tensor([0.0, 0.0, 0.0, 1.0]), torch.tensor([0.0, 0.0, 1.0, 1.0]), None, ), - ( # AssertionError - no intersection since ray is within the plane. + ( # ValueError - no intersection since ray is within the plane. torch.tensor([1.0, 0.0, 0.0, 0.0]), torch.tensor([0.0, 0.0, 1.0, 0.0]), torch.tensor([0.0, 0.0, 0.0, 1.0]), @@ -108,15 +90,16 @@ def test_line_plane_intersection( AssertionError If test does not complete as expected. """ + # Check if the ValueError is thrown as expected. if expected_intersections is None: - # Check if an AssertionError is thrown as expected. - with pytest.raises(AssertionError): + with pytest.raises(ValueError) as exc_info: raytracing_utils.line_plane_intersections( ray_directions=ray_directions.to(device), plane_normal_vectors=plane_normal_vectors.to(device), plane_center=plane_center.to(device), points_at_ray_origin=points_at_ray_origin.to(device), ) + assert "No intersection or line is within plane." in str(exc_info.value) else: # Check if the intersections match the expected intersections. intersections = raytracing_utils.line_plane_intersections( diff --git a/tests/raytracing/test_rays.py b/tests/raytracing/test_rays.py new file mode 100644 index 000000000..7f251424e --- /dev/null +++ b/tests/raytracing/test_rays.py @@ -0,0 +1,36 @@ +import pytest +import torch + +from artist.raytracing.rays import Rays + + +@pytest.mark.parametrize( + "ray_directions, ray_magnitudes", + [ + (torch.rand(10, 4, 1000, 4), torch.rand(9, 4, 1000)), + (torch.rand(10, 3, 1000, 4), torch.rand(10, 4, 1000)), + (torch.rand(10, 4, 100, 4), torch.rand(10, 4, 1000)), + ], +) +def test_ray_initialization_error( + ray_directions: torch.Tensor, ray_magnitudes: torch.Tensor +) -> None: + """ + Test errors during the ray initialization. + + Parameters + ---------- + ray_directions : torch.Tensor + The direction of the rays. + ray_magnitudes : torch.Tensor + The magnitudes of the rays. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + # Check if the ValueError is thrown as expected. + with pytest.raises(ValueError) as exc_info: + Rays(ray_directions=ray_directions, ray_magnitudes=ray_magnitudes) + assert "Ray directions and magnitudes have differing sizes!" in str(exc_info.value) diff --git a/tests/raytracing/test_reflect.py b/tests/raytracing/test_reflect.py index 245bc8999..da5ae9cfe 100644 --- a/tests/raytracing/test_reflect.py +++ b/tests/raytracing/test_reflect.py @@ -4,24 +4,6 @@ from artist.raytracing import raytracing_utils -@pytest.fixture(params=["cpu", "cuda"] if torch.cuda.is_available() else ["cpu"]) -def device(request: pytest.FixtureRequest) -> torch.device: - """ - Return the device on which to initialize tensors. - - Parameters - ---------- - request : pytest.FixtureRequest - The pytest fixture used to consider different test cases. - - Returns - ------- - torch.device - The device on which to initialize tensors. - """ - return torch.device(request.param) - - @pytest.mark.parametrize( "incoming_ray_direction, surface_normals, expected_reflection", [ diff --git a/tests/scene/test_light_source.py b/tests/scene/test_light_source.py index 997133b7b..42540f26f 100644 --- a/tests/scene/test_light_source.py +++ b/tests/scene/test_light_source.py @@ -7,24 +7,6 @@ from artist.util import config_dictionary -@pytest.fixture(params=["cpu", "cuda"] if torch.cuda.is_available() else ["cpu"]) -def device(request: pytest.FixtureRequest) -> torch.device: - """ - Return the device on which to initialize tensors. - - Parameters - ---------- - request : pytest.FixtureRequest - The pytest fixture used to consider different test cases. - - Returns - ------- - torch.device - The device on which to initialize tensors. - """ - return torch.device(request.param) - - def calculate_expected( distribution_parameters: Dict[str, Any], further_parameters: Dict[str, int], @@ -126,6 +108,23 @@ def distribution_parameters_3() -> Dict[str, Any]: } +@pytest.fixture +def distribution_parameters_4() -> Dict[str, Any]: + """ + Fixture that returns distribution parameters for the sun. + + Returns + ------- + Dict[str, Any] + Distribution parameters for the sun. + """ + return { + config_dictionary.light_source_distribution_type: "invalid_distribution_type", + config_dictionary.light_source_mean: 10, + config_dictionary.light_source_covariance: 15, + } + + @pytest.fixture def further_parameters_1() -> Dict[str, int]: """ @@ -184,17 +183,18 @@ def further_parameters_3() -> Dict[str, int]: @pytest.mark.parametrize( - "light_source, distribution_parameters_fixture, further_parameters_fixture", + "light_source, distribution_parameters_fixture, further_parameters_fixture, expected_error", [ - ("sun", "distribution_parameters_1", "further_parameters_1"), - ("sun", "distribution_parameters_2", "further_parameters_2"), - ("sun", "distribution_parameters_3", "further_parameters_3"), - ("sun", "distribution_parameters_1", "further_parameters_2"), - ("sun", "distribution_parameters_1", "further_parameters_3"), - ("sun", "distribution_parameters_2", "further_parameters_1"), - ("sun", "distribution_parameters_2", "further_parameters_3"), - ("sun", "distribution_parameters_3", "further_parameters_1"), - ("sun", "distribution_parameters_3", "further_parameters_2"), + ("sun", "distribution_parameters_1", "further_parameters_1", False), + ("sun", "distribution_parameters_2", "further_parameters_2", False), + ("sun", "distribution_parameters_3", "further_parameters_3", False), + ("sun", "distribution_parameters_1", "further_parameters_2", False), + ("sun", "distribution_parameters_1", "further_parameters_3", False), + ("sun", "distribution_parameters_2", "further_parameters_1", False), + ("sun", "distribution_parameters_2", "further_parameters_3", False), + ("sun", "distribution_parameters_3", "further_parameters_1", False), + ("sun", "distribution_parameters_3", "further_parameters_2", False), + ("sun", "distribution_parameters_4", "further_parameters_2", True), ], ) def test_light_sources( @@ -202,6 +202,7 @@ def test_light_sources( light_source: str, distribution_parameters_fixture: str, further_parameters_fixture: str, + expected_error: bool, device: torch.device, ) -> None: """ @@ -216,7 +217,9 @@ def test_light_sources( distribution_parameters_fixture : str The pytest fixture containing the distribution parameters. further_parameters_fixture : str - The pytest fixture containing the further test parameters. + The pytest fixture containing the further test parameters or ``None````None`` if an error is expected. + expected_error : bool + Specifies if ValueError is expected or not. device : torch.device The device on which to initialize tensors. @@ -230,23 +233,35 @@ def test_light_sources( # Run test if light source is a sun. if light_source == "sun": - sun = Sun( - distribution_parameters=request.getfixturevalue( - distribution_parameters_fixture - ), - number_of_rays=further_params_dict["num_rays"], - device=device, - ) - distortions_u, distortions_e = sun.get_distortions( - number_of_points=further_params_dict["num_points"], - number_of_heliostats=further_params_dict["num_heliostats"], - number_of_facets=further_params_dict["num_facets"], - random_seed=further_params_dict["random_seed"], - ) - expected_u, expected_e = calculate_expected( - request.getfixturevalue(distribution_parameters_fixture), - request.getfixturevalue(further_parameters_fixture), - device=device, - ) - torch.testing.assert_close(distortions_u, expected_u) - torch.testing.assert_close(distortions_e, expected_e) + # Check if the ValueError is thrown as expected. + if expected_error: + with pytest.raises(ValueError) as exc_info: + Sun( + distribution_parameters=request.getfixturevalue( + distribution_parameters_fixture + ), + number_of_rays=further_params_dict["num_rays"], + device=device, + ) + assert "Unknown sunlight distribution type." in str(exc_info.value) + else: + sun = Sun( + distribution_parameters=request.getfixturevalue( + distribution_parameters_fixture + ), + number_of_rays=further_params_dict["num_rays"], + device=device, + ) + distortions_u, distortions_e = sun.get_distortions( + number_of_points=further_params_dict["num_points"], + number_of_heliostats=further_params_dict["num_heliostats"], + number_of_facets=further_params_dict["num_facets"], + random_seed=further_params_dict["random_seed"], + ) + expected_u, expected_e = calculate_expected( + request.getfixturevalue(distribution_parameters_fixture), + request.getfixturevalue(further_parameters_fixture), + device=device, + ) + torch.testing.assert_close(distortions_u, expected_u) + torch.testing.assert_close(distortions_e, expected_e) diff --git a/tests/scene/test_load_light_source_array.py b/tests/scene/test_load_light_source_array.py new file mode 100644 index 000000000..f1fa59aeb --- /dev/null +++ b/tests/scene/test_load_light_source_array.py @@ -0,0 +1,57 @@ +import h5py +import pytest +import torch +from pytest_mock import MockerFixture + +from artist.scene.light_source_array import LightSourceArray +from artist.util import config_dictionary + + +def test_light_source_array_load_from_hdf5_errors( + mocker: MockerFixture, + device: torch.device, +) -> None: + """ + Test errors raised when loading a light source array from an hdf5 file. + + Parameters + ---------- + mocker : MockerFixture + A pytest-mocker fixture used to create mock objects. + device : torch.device + The device on which to initialize tensors. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + mock_h5_file = mocker.MagicMock(spec=h5py.File) + + mock_level_3 = mocker.MagicMock() + mock_level_3.__getitem__.return_value = b"laser" + + mock_level_2 = mocker.MagicMock() + mock_level_2.__getitem__.side_effect = lambda key: { + config_dictionary.light_source_type: mock_level_3 + }[key] + mock_level_2.keys.return_value = [config_dictionary.light_source_type] + + mock_level_1 = mocker.MagicMock() + mock_level_1.__getitem__.side_effect = lambda key: { + config_dictionary.sun_key: mock_level_2 + }[key] + mock_level_1.keys.return_value = [config_dictionary.sun_key] + + mock_h5_file.__getitem__.side_effect = lambda key: { + config_dictionary.light_source_key: mock_level_1 + }[key] + + with pytest.raises(KeyError) as exc_info: + LightSourceArray.from_hdf5( + config_file=mock_h5_file, + device=device, + ) + assert "Currently the selected light source: laser is not supported." in str( + exc_info.value + ) diff --git a/tests/util/test_3d_to_4d_converters.py b/tests/util/test_3d_to_4d_converters.py new file mode 100644 index 000000000..83937ff80 --- /dev/null +++ b/tests/util/test_3d_to_4d_converters.py @@ -0,0 +1,121 @@ +from typing import Optional + +import pytest +import torch + +from artist.util import utils + + +@pytest.mark.parametrize( + "point, expected", + [ + ( + torch.tensor([1.0, 0.0, 0.0]), + torch.tensor([1.0, 0.0, 0.0, 1.0]), + ), + ( + torch.tensor([1.0, 0.0]), + None, + ), + ( + torch.tensor([1.0, 0.0, 0.0, 0.0]), + None, + ), + ], +) +def test_3d_point_converter( + point: torch.Tensor, expected: Optional[torch.Tensor], device: torch.device +) -> None: + """ + Test the 3d to 4d point converter. + + Parameters + ---------- + point : torch.Tensor + A 3d point. + exxpected : Optional[torch.Tensor] + A 4d point or ``None`` if an error is expected. + device : torch.device + The device on which to initialize tensors. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + # Check if the ValueError is thrown as expected. + if expected is None: + with pytest.raises(ValueError) as exc_info: + utils.convert_3d_point_to_4d_format( + point=point.to(device), + device=device, + ) + assert f"Expected a 3D point but got a point of shape {point.shape}!" in str( + exc_info.value + ) + else: + # Check if the 4d point is correct. + point_4d = utils.convert_3d_point_to_4d_format( + point=point.to(device), + device=device, + ) + torch.testing.assert_close(point_4d, expected.to(device), rtol=1e-4, atol=1e-4) + + +@pytest.mark.parametrize( + "direction, expected", + [ + ( + torch.tensor([1.0, 0.0, 0.0]), + torch.tensor([1.0, 0.0, 0.0, 0.0]), + ), + ( + torch.tensor([1.0, 0.0]), + None, + ), + ( + torch.tensor([1.0, 0.0, 0.0, 0.0]), + None, + ), + ], +) +def test_3d_direction_converter( + direction: torch.Tensor, expected: Optional[torch.Tensor], device: torch.device +) -> None: + """ + Test the 3d to 4d point converter. + + Parameters + ---------- + direction : torch.Tensor + A 3d direction. + exxpected : Optional[torch.Tensor] + A 4d direction or ``None`` if an error is expected. + device : torch.device + The device on which to initialize tensors. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + # Check if the ValueError is thrown as expected. + if expected is None: + with pytest.raises(ValueError) as exc_info: + utils.convert_3d_direction_to_4d_format( + direction=direction.to(device), + device=device, + ) + assert ( + f"Expected a 3D direction but got a direction of shape {direction.shape}!" + in str(exc_info.value) + ) + else: + # Check if the 4d point is correct. + direction_4d = utils.convert_3d_direction_to_4d_format( + direction=direction.to(device), + device=device, + ) + torch.testing.assert_close( + direction_4d, expected.to(device), rtol=1e-4, atol=1e-4 + ) diff --git a/tests/util/test_alignment_optimizer_methods.py b/tests/util/test_alignment_optimizer_methods.py new file mode 100644 index 000000000..d3b4608ea --- /dev/null +++ b/tests/util/test_alignment_optimizer_methods.py @@ -0,0 +1,148 @@ +import pathlib + +import h5py +import pytest +import torch + +from artist import ARTIST_ROOT +from artist.scenario import Scenario +from artist.util import config_dictionary, paint_loader, set_logger_config, utils +from artist.util.alignment_optimizer import AlignmentOptimizer + +# Set up logger. +set_logger_config() + + +@pytest.mark.parametrize( + "optimizer_method, scenario_name, calibration_file, tolerance, max_epoch, initial_lr, lr_factor, lr_patience, lr_threshold", + [ + ( + "use_motor_positions", + "test_scenario_paint", + "calibration_properties", + 1e-7, + 150, + 0.01, + 0.1, + 20, + 0.1, + ), + ( + "use_raytracing", + "test_scenario_paint", + "calibration_properties", + 1e-7, + 27, + 0.0002, + 0.1, + 18, + 0.1, + ), + ], +) +def test_alignment_optimizer_methods( + optimizer_method: str, + scenario_name: str, + calibration_file: str, + tolerance: float, + max_epoch: int, + initial_lr: float, + lr_factor: float, + lr_patience: int, + lr_threshold: float, + device: torch.device, +) -> None: + """ + Test the alignemnt optimization methods. + + Parameters + ---------- + optimizer_method : str + The name of the optimizer method. + scenario_name : str + The name of the test scenario. + calibration_file : str + The file containing calibration data. + tolerance : float + Tolerance for the optimizer. + max_epoch : int + The maximum amount of epochs for the optimization loop. + initial_lr : float + The initial learning rate. + lr_factor : float + The scheduler factor. + lr_patience : int + The scheduler patience. + lr_threshold : float + The scheduler threshold. + device : torch.device + The device on which to initialize tensors. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + torch.manual_seed(7) + torch.cuda.manual_seed(7) + + scenario_path = pathlib.Path(ARTIST_ROOT) / f"tests/data/{scenario_name}.h5" + with h5py.File(scenario_path, "r") as scenario_file: + scenario = Scenario.load_scenario_from_hdf5( + scenario_file=scenario_file, device=device + ) + + optimizable_parameters = utils.get_rigid_body_kinematic_parameters_from_scenario( + kinematic=scenario.heliostats.heliostat_list[0].kinematic + ) + + optimizer = torch.optim.Adam(optimizable_parameters, lr=initial_lr) + scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( + optimizer, + mode="min", + factor=lr_factor, + patience=lr_patience, + threshold=lr_threshold, + threshold_mode="abs", + ) + + # Load the calibration data. + calibration_properties_path = ( + pathlib.Path(ARTIST_ROOT) / f"tests/data/{calibration_file}.json" + ) + + center_calibration_image, incident_ray_direction, motor_positions = ( + paint_loader.extract_paint_calibration_data( + calibration_properties_path=calibration_properties_path, + power_plant_position=scenario.power_plant_position, + device=device, + ) + ) + + # Create alignment optimizer. + alignment_optimizer = AlignmentOptimizer( + scenario=scenario, + optimizer=optimizer, + scheduler=scheduler, + ) + + if optimizer_method == config_dictionary.optimizer_use_raytracing: + motor_positions = None + + optimized_parameters, _ = alignment_optimizer.optimize( + tolerance=tolerance, + max_epoch=max_epoch, + center_calibration_image=center_calibration_image, + incident_ray_direction=incident_ray_direction, + motor_positions=motor_positions, + device=device, + ) + + expected_path = ( + pathlib.Path(ARTIST_ROOT) + / "tests/data/expected_optimized_alignment_parameters" + / f"{optimizer_method}_{device.type}.pt" + ) + expected = torch.load(expected_path, map_location=device, weights_only=True) + + torch.testing.assert_close(optimized_parameters, expected, atol=0.01, rtol=0.01) diff --git a/tests/util/test_all_forward.py b/tests/util/test_all_forward.py new file mode 100644 index 000000000..acad3c4f7 --- /dev/null +++ b/tests/util/test_all_forward.py @@ -0,0 +1,118 @@ +import types + +import pytest +import torch +from pytest_mock import MockerFixture + +from artist.field.actuator import Actuator +from artist.field.actuator_array import ActuatorArray +from artist.field.actuator_ideal import IdealActuator +from artist.field.actuator_linear import LinearActuator +from artist.field.facets_nurbs import NurbsFacet +from artist.field.heliostat import Heliostat +from artist.field.heliostat_field import HeliostatField +from artist.field.kinematic import Kinematic +from artist.field.kinematic_rigid_body import RigidBody +from artist.field.receiver import Receiver +from artist.field.receiver_field import ReceiverField +from artist.field.surface import Surface +from artist.scene.light_source import LightSource +from artist.scene.light_source_array import LightSourceArray +from artist.scene.sun import Sun +from artist.util.nurbs import NURBSSurface + + +@pytest.mark.parametrize( + "module", + [ + ActuatorArray, + LinearActuator, + IdealActuator, + NurbsFacet, + HeliostatField, + Heliostat, + RigidBody, + ReceiverField, + Receiver, + Surface, + LightSourceArray, + Sun, + NURBSSurface, + ], +) +def test_forward_errors_of_subclasses( + mocker: MockerFixture, + module: torch.nn.Module, +) -> None: + """ + Test the forward method of torch.nn.Module. + + Parameters + ---------- + mocker : MockerFixture + A pytest-mocker fixture used to create mock objects. + module : torch.nn.Module + A torch.nn.Module with a forward method. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + original_init = torch.nn.Module.__init__ + + def mock_init(self): + original_init(self) + + mocker.patch.object(module, "__init__", mock_init) + + module_instance = module() + + module_instance.forward = types.MethodType(module.forward, module_instance) + + with pytest.raises(NotImplementedError) as exc_info: + module_instance() + assert "Not Implemented!" in str(exc_info.value) + + +@pytest.mark.parametrize( + "module", + [ + Actuator, + Kinematic, + LightSource, + ], +) +def test_forward_errors_of_base_classes( + mocker: MockerFixture, + module: torch.nn.Module, +) -> None: + """ + Test the forward method of torch.nn.Module. + + Parameters + ---------- + mocker : MockerFixture + A pytest-mocker fixture used to create mock objects. + module : torch.nn.Module + A torch.nn.Module with a forward method. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + original_init = torch.nn.Module.__init__ + + def mock_init(self): + original_init(self) + + mocker.patch.object(module, "__init__", mock_init) + + module_instance = module() + + module_instance.forward = types.MethodType(module.forward, module_instance) + + with pytest.raises(NotImplementedError) as exc_info: + module_instance() + assert "Must be overridden!" in str(exc_info.value) diff --git a/tests/util/test_distortion_rotations.py b/tests/util/test_distortion_rotations.py index 1a604fdf7..8c6ec2dd5 100644 --- a/tests/util/test_distortion_rotations.py +++ b/tests/util/test_distortion_rotations.py @@ -6,24 +6,6 @@ from artist.util.utils import rotate_distortions -@pytest.fixture(params=["cpu", "cuda"] if torch.cuda.is_available() else ["cpu"]) -def device(request: pytest.FixtureRequest) -> torch.device: - """ - Return the device on which to initialize tensors. - - Parameters - ---------- - request : pytest.FixtureRequest - The pytest fixture used to consider different test cases. - - Returns - ------- - torch.device - The device on which to initialize tensors. - """ - return torch.device(request.param) - - @pytest.mark.parametrize( "e_distortions, u_distortions, rays_to_rotate, expected_distorted_rays", [ @@ -277,6 +259,12 @@ def device(request: pytest.FixtureRequest) -> torch.device: ] ), ), + ( # Test raise ValueError + torch.tensor([[[math.pi / 2]]]), + torch.tensor([[[0.0], [0.0]]]), + torch.tensor([[[1.0, 1.0, 1.0, 0.0]]]), + None, + ), ], ) def test_distortion_rotations( @@ -307,11 +295,26 @@ def test_distortion_rotations( AssertionError If test does not complete as expected. """ - distorted_rays = ( - rotate_distortions( - e=e_distortions.to(device), u=u_distortions.to(device), device=device + if expected_distorted_rays is None: + with pytest.raises(ValueError) as exc_info: + distorted_rays = ( + rotate_distortions( + e=e_distortions.to(device), + u=u_distortions.to(device), + device=device, + ) + @ rays_to_rotate.to(device).unsqueeze(-1) + ).squeeze(-1) + assert ( + "The two tensors containing angles for the east and up rotation must have the same shape." + in str(exc_info.value) ) - @ rays_to_rotate.to(device).unsqueeze(-1) - ).squeeze(-1) + else: + distorted_rays = ( + rotate_distortions( + e=e_distortions.to(device), u=u_distortions.to(device), device=device + ) + @ rays_to_rotate.to(device).unsqueeze(-1) + ).squeeze(-1) - torch.testing.assert_close(distorted_rays, expected_distorted_rays.to(device)) + torch.testing.assert_close(distorted_rays, expected_distorted_rays.to(device)) diff --git a/tests/util/test_paint_loader.py b/tests/util/test_paint_loader.py new file mode 100644 index 000000000..60da8b8cb --- /dev/null +++ b/tests/util/test_paint_loader.py @@ -0,0 +1,219 @@ +import pathlib +from typing import Any + +import pytest +import torch + +from artist import ARTIST_ROOT +from artist.util import paint_loader +from artist.util.configuration_classes import ActuatorListConfig, KinematicConfig + + +@pytest.mark.parametrize( + "file_path, expected", + [ + ( + pathlib.Path(ARTIST_ROOT) / "tests/data/calibration_properties.json", + "multi_focus_tower", + ) + ], +) +def test_extract_paint_calibration_target_name( + file_path: pathlib.Path, + expected: str, +) -> None: + """ + Test the function to load the calibration target name from ``PAINT`` calibration data. + + Parameters + ---------- + file_path : pathlib.Path + The path to the calibration file. + expected : str + The expected extracted data. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + calibration_target_name = paint_loader.extract_paint_calibration_target_name( + calibration_properties_path=file_path + ) + + assert calibration_target_name == expected + + +@pytest.mark.parametrize( + "file_path, power_plant_position, expected_list", + [ + ( + pathlib.Path(ARTIST_ROOT) / "tests/data/calibration_properties.json", + torch.tensor([50.91342112259258, 6.387824755874856, 87.0]), + [ + torch.tensor( + [-18.332265853882, -2.928076744080, 52.565364837646, 1.000000000000] + ), + torch.tensor( + [-0.596934497356, -0.437113255262, 0.672756373882, 0.000000000000] + ), + torch.tensor([24282, 43957]), + ], + ) + ], +) +def test_extract_paint_calibration_data( + file_path: pathlib.Path, + power_plant_position: torch.Tensor, + expected_list: list[torch.Tensor], + device: torch.device, +) -> None: + """ + Test the functino to extract calibration data from ``PAINT`` calibration data. + + Parameters + ---------- + file_path : pathlib.Path + The path to the calibration file. + power_plant_position : torch.Tensor + The power plant position. + expected_list : list[torch.Tensor] + The expected extracted data. + device : torch.device + The device on which to initialize tensors. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + extracted_list = list( + paint_loader.extract_paint_calibration_data( + calibration_properties_path=file_path, + power_plant_position=power_plant_position.to(device), + device=device, + ) + ) + + for actual, expected in zip(extracted_list, expected_list): + torch.testing.assert_close(actual, expected.to(device), atol=5e-4, rtol=5e-4) + + +@pytest.mark.parametrize( + "file_path, target_name, expected_list", + [ + ( + pathlib.Path(ARTIST_ROOT) / "tests/data/tower.json", + "multi_focus_tower", + [ + torch.tensor( + [50.913421122593, 6.387824755875, 87.000000000000], + dtype=torch.float64, + ), + "planar", + torch.tensor( + [-17.604515075684, -2.744643926620, 51.979751586914, 1.000000000000] + ), + torch.tensor([0, 1, 0, 0]), + torch.tensor(5.411863327026), + torch.tensor(6.387498855591), + ], + ) + ], +) +def test_extract_paint_tower_measurements( + file_path: pathlib.Path, + target_name: str, + expected_list: list[Any], + device: torch.device, +) -> None: + """ + Test the tower measurement loader for ``PAINT`` data. + + Parameters + ---------- + file_path : pathlib.Path + The path to the tower file. + target_name : str + The name of the target on the tower. + expected_list : list[Any] + The expected extracted data. + device : torch.device + The device on which to initialize tensors. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + extracted_list = list( + paint_loader.extract_paint_tower_measurements( + tower_measurements_path=file_path, target_name=target_name, device=device + ) + ) + + for actual, expected in zip(extracted_list, expected_list): + if isinstance(actual, torch.Tensor) and isinstance(expected, torch.Tensor): + torch.testing.assert_close( + actual, expected.to(device), atol=5e-4, rtol=5e-4 + ) + else: + assert actual == expected + + +@pytest.mark.parametrize( + "file_path, power_plant_position, expected_list", + [ + ( + pathlib.Path(ARTIST_ROOT) / "tests/data/heliostat_properties.json", + torch.tensor([50.91342112259258, 6.387824755874856, 87.0]), + [ + torch.tensor( + [11.738112449646, 24.784408569336, 1.794999957085, 1.000000000000] + ), + KinematicConfig, + ActuatorListConfig, + ], + ) + ], +) +def test_extract_paint_heliostat_properties( + file_path: pathlib.Path, + power_plant_position: torch.Tensor, + expected_list: list[Any], + device: torch.device, +) -> None: + """ + Test the heliostat properties loader for ``PAINT`` data. + + Parameters + ---------- + file_path : pathlib.Path + The path to the heliostat file. + power_plant_position : torch.Tensor + The power plant position. + expected_list : list[Any] + The expected extracted data. + device : torch.device + The device on which to initialize tensors. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + extracted_list = list( + paint_loader.extract_paint_heliostat_properties( + heliostat_properties_path=file_path, + power_plant_position=power_plant_position.to(device), + device=device, + ) + ) + + for actual, expected in zip(extracted_list, expected_list): + if isinstance(actual, torch.Tensor) and isinstance(expected, torch.Tensor): + torch.testing.assert_close( + actual, expected.to(device), atol=5e-4, rtol=5e-4 + ) + else: + assert isinstance(actual, expected) diff --git a/tests/util/test_surface_converter.py b/tests/util/test_surface_converter.py new file mode 100644 index 000000000..68a611457 --- /dev/null +++ b/tests/util/test_surface_converter.py @@ -0,0 +1,110 @@ +import pathlib + +import pytest +import torch + +from artist import ARTIST_ROOT +from artist.util.configuration_classes import FacetConfig +from artist.util.surface_converter import SurfaceConverter + + +def test_surface_converter(device: torch.device) -> None: + """ + Test the surface converter with ``STRAL`` and ``PAINT`` files. + + Parameters + ---------- + device : torch.device + The device on which to initialize tensors. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + heliostat_file_path = ( + pathlib.Path(ARTIST_ROOT) / "tests/data/heliostat_properties.json" + ) + deflectometry_file_path = pathlib.Path(ARTIST_ROOT) / "tests/data/deflectometry.h5" + stral_file_path = pathlib.Path(ARTIST_ROOT) / "tests/data/stral_test_data.binp" + + surface_converter_normals = SurfaceConverter( + step_size=5000, + number_eval_points_e=10, + number_eval_points_n=10, + conversion_method="deflectometry", + number_control_points_e=5, + number_control_points_n=5, + max_epoch=1, + ) + + surface_converter_points = SurfaceConverter( + step_size=5000, + number_eval_points_e=10, + number_eval_points_n=10, + conversion_method="point_cloud", + number_control_points_e=5, + number_control_points_n=5, + max_epoch=1, + ) + + surface_config_paint = surface_converter_normals.generate_surface_config_from_paint( + heliostat_file_path=heliostat_file_path, + deflectometry_file_path=deflectometry_file_path, + device=device, + ) + + surface_config_stral = surface_converter_normals.generate_surface_config_from_stral( + stral_file_path=stral_file_path, + device=device, + ) + + surface_config_stral_points = ( + surface_converter_points.generate_surface_config_from_stral( + stral_file_path=stral_file_path, + device=device, + ) + ) + + assert isinstance(surface_config_paint, list) + assert isinstance(surface_config_stral, list) + assert isinstance(surface_config_stral_points, list) + assert all(isinstance(obj, FacetConfig) for obj in surface_config_paint) + assert all(isinstance(obj, FacetConfig) for obj in surface_config_stral) + assert all(isinstance(obj, FacetConfig) for obj in surface_config_stral_points) + + +def test_fit_nurbs_conversion_method_error(device: torch.device) -> None: + """ + Test fitting nurbs method for errors. + + Parameters + ---------- + device : torch.device + The device on which to initialize tensors. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + surface_converter = SurfaceConverter( + step_size=5000, + number_eval_points_e=10, + number_eval_points_n=10, + number_control_points_e=5, + number_control_points_n=5, + max_epoch=1, + ) + + with pytest.raises(NotImplementedError) as exc_info: + surface_converter.fit_nurbs_surface( + surface_points=torch.rand(10, 4, device=device), + surface_normals=torch.rand(10, 4, device=device), + conversion_method="invalid_method", + max_epoch=1, + device=device, + ) + assert "Conversion method invalid_method not yet implemented!" in str( + exc_info.value + ) diff --git a/tests/util/test_translate.py b/tests/util/test_translate.py new file mode 100644 index 000000000..d9d2eb17c --- /dev/null +++ b/tests/util/test_translate.py @@ -0,0 +1,94 @@ +import pytest +import torch + +from artist.util import utils + + +@pytest.mark.parametrize( + "east_translation, north_translation, up_translation, expected", + [ + ( + torch.tensor([1.0]), + torch.tensor([2.0]), + torch.tensor([3.0]), + torch.tensor( + [ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 2.0], + [0.0, 0.0, 1.0, 3.0], + [0.0, 0.0, 0.0, 1.0], + ] + ), + ), + ( + torch.tensor([1.0, 0.0, 0.0]), + torch.tensor([0.0, 1.0, 0.0, 1.0]), + torch.tensor([0.0, 0.0, 1.0, 1.0]), + None, + ), + ( + torch.tensor([1.0, 0.0, 0.0, 0.0]), + torch.tensor([0.0, 1.0, 0.0]), + torch.tensor([0.0, 0.0, 1.0, 1.0]), + None, + ), + ( + torch.tensor([1.0, 0.0, 0.0, 0.0]), + torch.tensor([0.0, 1.0, 0.0, 0.0]), + torch.tensor([0.0, 0.0, 1.0]), + None, + ), + ], +) +def test_translate_enu( + east_translation: torch.Tensor, + north_translation: torch.Tensor, + up_translation: torch.Tensor, + expected: torch.Tensor, + device: torch.device, +) -> None: + """ + Test that the correct translation matrix is created. + + Parameters + ---------- + east_translation : torch.Tensor + The translation in east direction. + north_translation : torch.Tensor + The translation in north direction. + up_translation : torch.Tensor + The translation in up direction. + expected : torch.Tensor + The expected overall translation or ``None`` if an error is expected. + device : torch.device + The device on which to initialize tensors. + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + # Check if the ValueError is thrown as expected. + if expected is None: + with pytest.raises(ValueError) as exc_info: + utils.translate_enu( + e=east_translation.to(device), + n=north_translation.to(device), + u=up_translation.to(device), + device=device, + ) + assert ( + "The three tensors containing the east, north, and up translations must have the same shape." + in str(exc_info.value) + ) + else: + # Check if the translation matrix is correct. + translation_matrix = utils.translate_enu( + e=east_translation.to(device), + n=north_translation.to(device), + u=up_translation.to(device), + device=device, + ) + torch.testing.assert_close( + translation_matrix, expected.to(device), rtol=1e-4, atol=1e-4 + ) diff --git a/tests/util/test_wgs84_to_enu.py b/tests/util/test_wgs84_to_enu.py new file mode 100644 index 000000000..6dcf7f42b --- /dev/null +++ b/tests/util/test_wgs84_to_enu.py @@ -0,0 +1,57 @@ +import pytest +import torch + +from artist.util import utils + + +@pytest.mark.parametrize( + "wgs84_coordinates, reference_point, expected_enu_coordinates", + [ + # Coordinates of Jülich power plant and multi-focus tower. + ( + ( + torch.tensor( + [50.91339645088695, 6.387574436728054, 138.97975], + dtype=torch.float64, + ), + torch.tensor( + [50.913421630859, 6.387824755874856, 87.000000000000], + dtype=torch.float64, + ), + torch.tensor([-17.6045, -2.8012, 51.9798]), + ) + ), + ], +) +def test_wgs84_to_enu_converter( + wgs84_coordinates: torch.Tensor, + reference_point: torch.Tensor, + expected_enu_coordinates: torch.Tensor, + device: torch.device, +) -> None: + """ + Test the WGS84 to ENU conversion. + + Parameters + ---------- + wgs84_coordinates : torch.Tensor + The coordinates in latitude, longitude, altitude that are to be transformed. + reference_point : torch.Tensor + The center of origin of the ENU coordinate system in WGS84 coordinates. + expected_enu_coordinates : torch.Tensor + The expected enu coordinates. + device : Union[torch.device, str] + The device on which to initialize tensors (default is cuda). + + Raises + ------ + AssertionError + If test does not complete as expected. + """ + calculated_enu_coordinates = utils.convert_wgs84_coordinates_to_local_enu( + wgs84_coordinates.to(device), reference_point.to(device), device + ) + + torch.testing.assert_close( + calculated_enu_coordinates, expected_enu_coordinates.to(device) + ) diff --git a/tutorials/01_generate_scenario_heliostat_raytracing.py b/tutorials/01_generate_scenario_heliostat_raytracing.py index a64ccca22..338915455 100644 --- a/tutorials/01_generate_scenario_heliostat_raytracing.py +++ b/tutorials/01_generate_scenario_heliostat_raytracing.py @@ -1,5 +1,4 @@ -import math -from pathlib import Path +import pathlib import torch @@ -10,34 +9,41 @@ ActuatorPrototypeConfig, HeliostatConfig, HeliostatListConfig, - KinematicOffsets, KinematicPrototypeConfig, LightSourceConfig, LightSourceListConfig, + PowerPlantConfig, PrototypeConfig, ReceiverConfig, ReceiverListConfig, SurfacePrototypeConfig, ) from artist.util.scenario_generator import ScenarioGenerator -from artist.util.stral_to_surface_converter import StralToSurfaceConverter +from artist.util.surface_converter import SurfaceConverter + +device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # The following parameter is the name of the scenario. -file_path = "please/insert/your/path/here/name" +file_path = pathlib.Path(ARTIST_ROOT) / "please/insert/your/path/here/name" # This checks to make sure the path you defined is valid and a scenario HDF5 can be saved there. -if not Path(file_path).parent.is_dir(): +if not pathlib.Path(file_path).parent.is_dir(): raise FileNotFoundError( - f"The folder ``{Path(file_path).parent}`` selected to save the scenario does not exist. " + f"The folder ``{pathlib.Path(file_path).parent}`` selected to save the scenario does not exist. " "Please create the folder or adjust the file path before running again!" ) +# Include the power plant configuration. +power_plant_config = PowerPlantConfig( + power_plant_position=torch.tensor([0.0, 0.0, 0.0], device=device) +) + # Include the receiver configuration. receiver1_config = ReceiverConfig( receiver_key="receiver1", receiver_type=config_dictionary.receiver_type_planar, - position_center=torch.tensor([0.0, -50.0, 0.0, 1.0]), - normal_vector=torch.tensor([0.0, 1.0, 0.0, 0.0]), + position_center=torch.tensor([0.0, -50.0, 0.0, 1.0], device=device), + normal_vector=torch.tensor([0.0, 1.0, 0.0, 0.0], device=device), plane_e=8.629666667, plane_u=7.0, resolution_e=256, @@ -67,54 +73,36 @@ light_source_list_config = LightSourceListConfig(light_source_list=light_source_list) # Generate surface configuration from STRAL data. -stral_converter = StralToSurfaceConverter( - stral_file_path=f"{ARTIST_ROOT}/measurement_data/stral_test_data", - surface_header_name="=5f2I2f", - facet_header_name="=i9fI", - points_on_facet_struct_name="=7f", +surface_converter = SurfaceConverter( step_size=100, + max_epoch=400, ) -facet_prototype_list = stral_converter.generate_surface_config_from_stral( - number_eval_points_e=200, - number_eval_points_n=200, - conversion_method=config_dictionary.convert_nurbs_from_normals, - number_control_points_e=20, - number_control_points_n=20, - degree_e=3, - degree_n=3, - tolerance=3e-5, - max_epoch=10000, - initial_learning_rate=1e-3, +facet_prototype_list = surface_converter.generate_surface_config_from_stral( + stral_file_path=file_path, device=device ) # Generate the surface prototype configuration. surface_prototype_config = SurfacePrototypeConfig(facets_list=facet_prototype_list) # Note that we do not include kinematic deviations in this scenario! - -# Include the initial orientation offsets for the kinematic. -kinematic_prototype_offsets = KinematicOffsets( - kinematic_initial_orientation_offset_e=torch.tensor(math.pi / 2) -) - # Include the kinematic prototype configuration. kinematic_prototype_config = KinematicPrototypeConfig( - kinematic_type=config_dictionary.rigid_body_key, - kinematic_initial_orientation_offsets=kinematic_prototype_offsets, + type=config_dictionary.rigid_body_key, + initial_orientation=torch.Tensor([0.0, 0.0, 1.0, 0.0], device=device), ) # Include an ideal actuator. actuator1_prototype = ActuatorConfig( - actuator_key="actuator1", - actuator_type=config_dictionary.ideal_actuator_key, - actuator_clockwise=False, + key="actuator1", + type=config_dictionary.ideal_actuator_key, + clockwise_axis_movement=False, ) # Include a second ideal actuator. actuator2_prototype = ActuatorConfig( - actuator_key="actuator2", - actuator_type=config_dictionary.ideal_actuator_key, - actuator_clockwise=True, + key="actuator2", + type=config_dictionary.ideal_actuator_key, + clockwise_axis_movement=True, ) # Create a list of actuators. @@ -138,8 +126,8 @@ heliostat1 = HeliostatConfig( heliostat_key="heliostat1", heliostat_id=1, - heliostat_position=torch.tensor([0.0, 5.0, 0.0, 1.0]), - heliostat_aim_point=torch.tensor([0.0, -50.0, 0.0, 1.0]), + heliostat_position=torch.tensor([0.0, 5.0, 0.0, 1.0], device=device), + heliostat_aim_point=torch.tensor([0.0, -50.0, 0.0, 1.0], device=device), ) # Create a list of all the heliostats - in this case, only one. @@ -153,8 +141,9 @@ """Generate the scenario given the defined parameters.""" # Create a scenario object. - scenario_object = ScenarioGenerator( + scenario_generator = ScenarioGenerator( file_path=file_path, + power_plant_config=power_plant_config, receiver_list_config=receiver_list_config, light_source_list_config=light_source_list_config, prototype_config=prototype_config, @@ -162,4 +151,4 @@ ) # Generate the scenario. - scenario_object.generate_scenario() + scenario_generator.generate_scenario() diff --git a/tutorials/01_heliostat_raytracing_tutorial.py b/tutorials/01_heliostat_raytracing_tutorial.py index b5a71d946..8399b0b6f 100644 --- a/tutorials/01_heliostat_raytracing_tutorial.py +++ b/tutorials/01_heliostat_raytracing_tutorial.py @@ -1,6 +1,6 @@ import math import subprocess -from typing import Optional +from typing import Optional, Union import h5py import matplotlib.pyplot as plt @@ -22,10 +22,11 @@ result = subprocess.run(command, capture_output=True, text=True) scenario_name = output_filename +device = torch.device("cuda" if torch.cuda.is_available() else "cpu") # Load the scenario. with h5py.File(scenario_name, "r") as f: - example_scenario = Scenario.load_scenario_from_hdf5(scenario_file=f) + example_scenario = Scenario.load_scenario_from_hdf5(scenario_file=f, device=device) # Inspect the scenario. print(example_scenario) @@ -38,14 +39,16 @@ print(f"The heliostat is aiming at: {single_heliostat.aim_point}") # Define the incident ray direction for when the sun is in the south. -incident_ray_direction_south = torch.tensor([0.0, -1.0, 0.0, 0.0]) +incident_ray_direction_south = torch.tensor([0.0, -1.0, 0.0, 0.0], device=device) # Save original surface points. -original_surface_points, _ = single_heliostat.surface.get_surface_points_and_normals() +original_surface_points, _ = single_heliostat.surface.get_surface_points_and_normals( + device=device +) # Align the heliostat. -single_heliostat.set_aligned_surface( - incident_ray_direction=incident_ray_direction_south +single_heliostat.set_aligned_surface_with_incident_ray_direction( + incident_ray_direction=incident_ray_direction_south, device=device ) # Define colors for each facet. @@ -63,17 +66,17 @@ # Plot each facet for i in range(len(single_heliostat.surface.facets)): - e_origin = original_surface_points[i, :, 0].detach().numpy() - n_origin = original_surface_points[i, :, 1].detach().numpy() - u_origin = original_surface_points[i, :, 2].detach().numpy() + e_origin = original_surface_points[i, :, 0].cpu().detach().numpy() + n_origin = original_surface_points[i, :, 1].cpu().detach().numpy() + u_origin = original_surface_points[i, :, 2].cpu().detach().numpy() e_aligned = ( - single_heliostat.current_aligned_surface_points[i, :, 0].detach().numpy() + single_heliostat.current_aligned_surface_points[i, :, 0].cpu().detach().numpy() ) n_aligned = ( - single_heliostat.current_aligned_surface_points[i, :, 1].detach().numpy() + single_heliostat.current_aligned_surface_points[i, :, 1].cpu().detach().numpy() ) u_aligned = ( - single_heliostat.current_aligned_surface_points[i, :, 2].detach().numpy() + single_heliostat.current_aligned_surface_points[i, :, 2].cpu().detach().numpy() ) ax1.scatter(e_origin, n_origin, u_origin, color=colors[i], label=f"Facet {i+1}") ax2.scatter(e_aligned, n_aligned, u_aligned, color=colors[i], label=f"Facet {i+1}") @@ -110,17 +113,21 @@ raytracer = HeliostatRayTracer(scenario=example_scenario, batch_size=100) # Perform heliostat-based raytracing. -image_south = raytracer.trace_rays(incident_ray_direction=incident_ray_direction_south) +image_south = raytracer.trace_rays( + incident_ray_direction=incident_ray_direction_south, device=device +) image_south = raytracer.normalize_bitmap(image_south) # Plot the result. fig, ax = plt.subplots(figsize=(6, 6)) -ax.imshow(image_south.T.detach().numpy(), cmap="inferno") +ax.imshow(image_south.T.cpu().detach().numpy(), cmap="inferno") tight_layout() # Define helper functions to enable us to repeat the process! -def align_and_trace_rays(light_direction: torch.Tensor) -> torch.Tensor: +def align_and_trace_rays( + light_direction: torch.Tensor, device: Union[torch.device, str] = "cuda" +) -> torch.Tensor: """ Align the heliostat and perform heliostat raytracing. @@ -128,15 +135,19 @@ def align_and_trace_rays(light_direction: torch.Tensor) -> torch.Tensor: ---------- light_direction : torch.Tensor The direction of the incoming light on the heliostat. + device : Union[torch.device, str] + The device on which to initialize tensors (default: cuda). Returns ------- torch.Tensor A tensor containing the distribution strengths used to generate the image on the receiver. """ - single_heliostat.set_aligned_surface(incident_ray_direction=light_direction) + single_heliostat.set_aligned_surface_with_incident_ray_direction( + incident_ray_direction=light_direction, device=device + ) return raytracer.normalize_bitmap( - raytracer.trace_rays(incident_ray_direction=light_direction) + raytracer.trace_rays(incident_ray_direction=light_direction, device=device) ) @@ -173,7 +184,7 @@ def plot_multiple_images( # Plot each tensor. for i, image in enumerate(image_tensors): ax = axes[i] - ax.imshow(image.T.detach().numpy(), cmap="inferno") + ax.imshow(image.T.cpu().detach().numpy(), cmap="inferno") if names is not None and i < len(names): ax.set_title(names[i]) else: @@ -189,14 +200,20 @@ def plot_multiple_images( # Consider multiple incident ray directions and plot the result. # Define light directions. -incident_ray_direction_east = torch.tensor([1.0, 0.0, 0.0, 0.0]) -incident_ray_direction_west = torch.tensor([-1.0, 0.0, 0.0, 0.0]) -incident_ray_direction_above = torch.tensor([0.0, 0.0, 1.0, 0.0]) +incident_ray_direction_east = torch.tensor([1.0, 0.0, 0.0, 0.0], device=device) +incident_ray_direction_west = torch.tensor([-1.0, 0.0, 0.0, 0.0], device=device) +incident_ray_direction_above = torch.tensor([0.0, 0.0, 1.0, 0.0], device=device) # Perform alignment and raytracing to generate flux density images. -image_east = align_and_trace_rays(light_direction=incident_ray_direction_east) -image_west = align_and_trace_rays(light_direction=incident_ray_direction_west) -image_above = align_and_trace_rays(light_direction=incident_ray_direction_above) +image_east = align_and_trace_rays( + light_direction=incident_ray_direction_east, device=device +) +image_west = align_and_trace_rays( + light_direction=incident_ray_direction_west, device=device +) +image_above = align_and_trace_rays( + light_direction=incident_ray_direction_above, device=device +) # Plot the resulting images. plot_multiple_images( diff --git a/tutorials/02_alignment_optimization_tutorial.py b/tutorials/02_alignment_optimization_tutorial.py new file mode 100644 index 000000000..e19845b89 --- /dev/null +++ b/tutorials/02_alignment_optimization_tutorial.py @@ -0,0 +1,98 @@ +import pathlib + +import h5py +import torch + +from artist import ARTIST_ROOT +from artist.scenario import Scenario +from artist.util import paint_loader, set_logger_config, utils +from artist.util.alignment_optimizer import AlignmentOptimizer + +# If you have already generated the tutorial scenario yourself, you can leave this boolean as False. If not, set it to +# true and a pre-generated scenario file will be used for this tutorial! +use_pre_generated_scenario = True +scenario_path = ( + pathlib.Path(ARTIST_ROOT) / "please/insert/the/path/to/the/scenario/here/name.h5" +) +if use_pre_generated_scenario: + scenario_path = ( + pathlib.Path(ARTIST_ROOT) + / "tutorials/data/test_scenario_alignment_optimization.h5" + ) + +# Set up logger. +set_logger_config() + +# Set the device. +device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + +# Load the scenario. +with h5py.File(scenario_path, "r") as scenario_file: + example_scenario = Scenario.load_scenario_from_hdf5( + scenario_file=scenario_file, device=device + ) + +# Choose calibration data. +calibration_properties_path = ( + pathlib.Path(ARTIST_ROOT) / "tutorials/data/test_calibration_properties.json" +) + +# Load the calibration data. +center_calibration_image, incident_ray_direction, motor_positions = ( + paint_loader.extract_paint_calibration_data( + calibration_properties_path=calibration_properties_path, + power_plant_position=example_scenario.power_plant_position, + device=device, + ) +) + +# Get optimizable parameters. This will select all 28 kinematic parameters. +parameters = utils.get_rigid_body_kinematic_parameters_from_scenario( + kinematic=example_scenario.heliostats.heliostat_list[0].kinematic +) + +# Set up optimizer and scheduler parameters. +tolerance = 1e-7 +max_epoch = 150 +initial_learning_rate = 0.001 +learning_rate_factor = 0.1 +learning_rate_patience = 20 +learning_rate_threshold = 0.1 + +use_raytracing = False +if use_raytracing: + motor_positions = None + tolerance = 1e-7 + max_epoch = 27 + initial_learning_rate = 0.0002 + learning_rate_factor = 0.1 + learning_rate_patience = 18 + learning_rate_threshold = 0.1 + +optimizer = torch.optim.Adam(parameters, lr=initial_learning_rate) + +# Set up learning rate scheduler. +scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( + optimizer, + mode="min", + factor=learning_rate_factor, + patience=learning_rate_patience, + threshold=learning_rate_threshold, + threshold_mode="abs", +) + +# Create alignment optimizer. +alignment_optimizer = AlignmentOptimizer( + scenario=example_scenario, + optimizer=optimizer, + scheduler=scheduler, +) + +optimized_parameters, optimized_scenario = alignment_optimizer.optimize( + tolerance=tolerance, + max_epoch=max_epoch, + center_calibration_image=center_calibration_image, + incident_ray_direction=incident_ray_direction, + motor_positions=motor_positions, + device=device, +) diff --git a/tutorials/data/test_calibration_properties.json b/tutorials/data/test_calibration_properties.json new file mode 100644 index 000000000..16753df69 --- /dev/null +++ b/tutorials/data/test_calibration_properties.json @@ -0,0 +1,21 @@ +{ + "motor_position": { + "Axis1MotorPosition": 26370, + "Axis2MotorPosition": 68271 + }, + "target_name": "multi_focus_tower", + "Sun_elevation": 26.928708354275802, + "Sun_azimuth": -53.97206490734055, + "focal_spot": { + "HeliOS": [ + 50.91339561778062, + 6.387576454737495, + 137.6960063958947 + ], + "UTIS": [ + 50.913395620694814, + 6.387576828273339, + 137.74108505249023 + ] + } +} diff --git a/tutorials/data/test_scenario_alignment_optimization.h5 b/tutorials/data/test_scenario_alignment_optimization.h5 new file mode 100644 index 000000000..86b2f573a Binary files /dev/null and b/tutorials/data/test_scenario_alignment_optimization.h5 differ diff --git a/tutorials/data/test_stral_data.binp b/tutorials/data/test_stral_data.binp new file mode 100644 index 000000000..970af8ab7 Binary files /dev/null and b/tutorials/data/test_stral_data.binp differ