# Optical Simulation SDK

## Begin

Our optical simulation SDK is released in a beta version and has only limited features support at the moment.
We are working on supporting more features and you can expect new versions to come out soon.

#### Install

The python package installation is available using PyPi:

```bash
pip install threed-optix
```

#### Get your API key

Get your API key from 3DOptix user interface (under "user settings"):

- Click on your email in the top right corner, and choose **"user settings"** in the drop-down
  ![User settings](https://i.yourimageshare.com/MyBdTqNzyQ.webp "User settings")
- On the buttom of the user settings manu, click on **"API"**
  ![API settings](https://i.yourimageshare.com/IbcB26QfJh.webp "API settings")
- **Copy** your API key
  ![Get API key](https://i.yourimageshare.com/tPq7LC8Qfy.webp "Get API key")

#### Start

`tdo.Client` object is used to communicate with 3DOptix databases and simulation engine.

```python
import threed_optix as tdo

#Your API key from 3DOptix user interface
api_key = '<your_api_key>'

#api is the object that manages the communication with 3DOptix systems
client = tdo.Client(api_key)
```

## Basics

### Setups

`tdo.Setup` are the objects that represent your simulation setups in the SDK.
You could access their information:

- `setup.name` is the setup name. It is not neceseraliy unique.
- `setup.id` is the id of the setup. It is unique.
- `setup.parts` is a list of `tdo.Part` objects that the setup contains.

#### Get a list of your 3DOptix setups:

```python
#'setups' will be a list of your simulation setups as a `tdo.Setup` objects
setups = client.get_setups()
```

#### Create a `tdo.Setup`

You could create a new setup with `client.create_setup` method. The method has the following arguments:
- `name`: str, The name of the setup.
- `description`: str, The description of the setup.
- `labels`: list[str], the labels of the setup. Can be:
    - "General"
    - "Microscopy"
    - "Telescopy"
    - "Spectroscopy"
    - "Imaging"
    - "Non-linear optics"
    - "Fiber"
    - "Illumination"
    - "Light sources"
    - "Laser Optics"
    - "Diffractive Optics"
- `units`: str, 'mm' or 'inch'. Default to 'mm'.
- `private`: bool, `True` for private setup and `False` for a public one. Default to `False`.

```python
setup = client.create_setup(name = name,
                            description = description,
                            labels = labels,
                            private = True)
```

#### Find a setup:

First, we need to identify the setup we want to work on:

```python
#Examine the setups:
for s in setups:
    print(s.name, s.id)
```

> **Note**
> A setup id is unique, but the name is not unique

Then, we can get the setup object by using `client.get(name)` and `client[id]` methods:

```python
#Get setup by name
setup_name = '<your setup name>'
setup = client.get(setup_name)

#Get setup by id
setup_id = '<your setup id'
setup = client[setup_id]
```

If the setups is not found, `client[setup_id]` **will** lead to an error, and `client.get(setup_name)` will **not**.

Another way to do this is by looping over the setps list:

```python
#Chosen setup id
setup_id = '<your setup id>'

#Get the 'Setup' object to work on
setup = None
for s in setups:
    #Get your setup by name
    if s.id == setup_id:
        setup = s
        break

assert setup is not None, "Setup was not found"
```

### Parts:

`tdo.Part` are the objects that represent your setup parts in the SDK.
You could access their information:

- `part.label` is the label of the part. It is not neceseraliy unique.
- `part.id` is the id of the setup. It is unique.
- `part.surfaces` is a list of `tdo.Surface` objects that the part has.
- `part.pose` is a list of 6 floats representing the part's position and rotation relative to their associated coordinate system.

> **Warning**
> Setups with parts that were loaded from a CAD file are not supported fully at the moment.
> These CAD parts will not lead to an error, but they might lead to unexpected behavior.

`tdo.Detector` is the object used to represent detectors. It inherits all properties and functionalities from `tdo.Part` while also introducing specific attributes tailored for representing detectors within the SDK.
`tdo.Detector` has the following additional information:

- `detector.size`
- `detector.opacity`

`tdo.LightSource` is the object used to represent light sources. It inherits all properties and functionalities from `tdo.Part` while also introducing specific attributes tailored for representing light sources within the SDK.
`tdo.LightSource` has the following additional information:

- `ls.wavelengths`
- `ls.power`
- `ls.rays_direction`
- `ls.vis_count`
- `ls.count_type`
- `ls.opacity`
- `ls.color`
- `ls.source_type`
- And information that changes with respect to `source_type`.

**You could see a full description of these objects, as well as how to modify them, later on this document**.

#### Add a `tdo.Part`:
To add a detector to the setup:
```python
detector = setup.add_detector(label = label,
                              pose = pose)
```

To add a light source to the setup:
```python
ls = setup.add_light_source(label = label,
                              pose = pose)
```

To add optic to the setup, we will have to find its `number_id` from the product catalog or take the number id of the created optic from `client.create_xxx` methods.
```python
ls = setup.add_light_source(number_id = number_id,
                            label = label,
                            pose = pose)
```

#### Find a part:

Almost identical to finding a setup.

```python
#Examine the parts of the setup
for part in setup:
    print(part.label, part.id)
```

> **Note**
> A part id is unique, but the label is not unique

Then, we can identify the part and start working, similarly to how we identified the setup:

```python
#Get part by label
part_label = '<your part label>'
part = setup.get(part_label)

#Get part by id
part_id = '<your part id'
part = setup[part_id]
```

If the part is not found, `setup[part_id]` **will** lead to an error, and `setup.get(part_label)` will **not**.

Or, alternatively, looping over the `tdo.Setup` object:

```python
#Chosen part id
part_id = '<the id of the part to change>'

#Get the 'Part' object to work on
part = None
for p in setup:
    #Get part by id
    if p.id == part_id:
       part = p
       break

assert part is not None, "Part was not found"
```

### Surface

`tdo.Surface` are the objects that represent the surfaces of the part in the SDK.
You could access their information:

- `surface.name` is the name of the surface. It is not neceseraliy unique.
- `surface.id` is the id of the setup. It is unique.
- `surface.analyses` is a list of `tdo.Analysis` objects that the surface has. Later on we discuss how to add new and run them.

#### Create or add a `tdo.Surface`

Creation of new surfaces is not possible.

#### Find a surface:

Almost identical to finding your setups and parts within your setups.

```python
for surface in part:
    print(surface.name, surface.id)
```

Then, we can identify the surface by `part.get()` and `part[]` methods:

```python
#Get surface by name
surface_name = '<your surface name>'
surface = part.get(surface_name)

#Get surface by id
surface_id = '<your surface id>'
surface = part[surface_id]
```

If the surface is not found, `part[surface_id]` **will** lead to an error, and `part.get(surface_name)` will **not**.

Or looping over the part:

```python
#Choose surface id
surface_id = '<the id of the surface to perform analysis on>'

#Get the 'tdo.Surface' object:
surface = None
for s in part:
    #Get surface by id
    if s.id == surface_id:
      surface = s
      break

assert surface is not None, "Surface was not found"
```

### Analysis

`tdo.Analysis` are the objects that represent analyses that can be performed on a surface.
They are defined by:

- `id`: The unique id of the analysis.
- `surface`: The `tdo.Surface` object on which the analysis will be made.
- `resolution`: The analysis surface resolution, in the format of `[pixels_x, pixels_y]` (up to 1e4)
- `rays`: A dictionary that maps how many rays to shoot from each light source (up to 1e9), in the format of `{ls_object: num_rays, ls_object: num_rays, ...}` where light source object is a `tdo.LightSource` object. The rays are distributed equally between the light source wavelengths.
- `name`: A string represents the analysis type. Currently supported:
  - "Spot (Incoherent Irradiance)",
  - "Spot (Coherent Irradiance)",
  - "Coherent Phase",
  - "Spot (Coherent Irradiance) Huygens",
  - "Coherent Phase Huygens",
  - "Spot (Coherent Irradiance) Polarized",
  - "Coherent Phase Polarized",
  - "Spot (Incoherent Irradiance) Polarized"

A surface can contain several analysis with identical properties and different ids.
However, each analysis consumes your account resources, since analysis id holds its results.
When you run the analysis again, the last result is deleted from 3DOptix systems.
So, we reccomend storing iterations of the same analysis locally and use duplicated analyses only when you think it's necessary.

You can always get a reminder of the possible analyses with `tdo.analysis_names` variable.

#### Find existing analysis

Every new analysis consumes storage resources for you account.
So, we reccomend sticking to the same analysis if they have the same properties instead of creating new ones.
You could get a list of the `tdo.Analysis` of the surface like this:

```python
setup = client.get('example')
detector = setup.get('my_detector')
detector_front = detector.get('front')
detector_front_analyses = detector_front.analyses

# Then, you could check their properties and choose the right one:

for analysis in detector_front_analyses:
    print(analysis)
```

Or get an existing analyses with required parameters, if the id doesn't matter:

```python
detector_front = setup.get('my_detector').get('front')
analysis = detector_front.find_analysis(name = name, rays = rays, resolution = resolution)
assert analysis
```

#### Create and add analysis

If you want to create an analysis that doesn't exist for the surface yet, or you want to create a duplicated analysis, we can create one:

```python
analysis = tdo.Analysis(surface: tdo.Surface,
                        name: str,
                        rays: dict {ls_object: int, ...}
                        resolution: list [int, int]
                        )
```

For example:

```python
laser = setup.get('ls')
surface = detector.get('front')

analysis = tdo.Analysis(
                        surface = surface,
                        resolution = [500, 500],
                        rays = {laser: 1e5},
                        name = "Spot (Incoherent Irradiance)"
                        )
```

> **Reminder**
> You can always access all the possible analysis names with `tdo.analysis_names`

After we created an analysis, we need to add it to the surface and setup to be able to run it.

```python
# In case the surface doesn't have analysis with this parameter
surface.add_analysis(analysis)

# In case that the surface has an analysis with these parameter, and we want to add a new one anyway
surface.add_analysis(analysis, force = True)
```

**If the analysis is duplicated**, you have to use `force = True` to indicate that you understand that you are adding a duplicated analysis that will use more storage.
Otherwise, you will get an error.

## Make Changes

#### Backup

Before making any changes, we reccomend storing the original part or setup, before any changes:

```python
#Backup the part
part = setup.get('part_label')
part.backup()

#Some code...

#Restore the original state of the part
part.restore()
```

In the case of multiple changes to multiple parts, you might find this easier:

```python
# Backup the setup
setup = client['setup_id']
setup.backup()

#Some code...

#Restore the original state of the part
setup.restore()
```

#### Transformations

You could move and rotate part using `part.change_pose()` method that moves the part to a location on the three axis (x, y, z) and rotates it in respect to them (alpha, beta, gamma).

All six numbers indicating absolute future value in respect to the part's coordinate system, **not** change and **not** with respect to the global coordinate system.

```python
#Define the rotation
new_pose = [x, y, z, alpha, beta, gamma]

#Apply on the part
part.change_pose(new_pose)

# In case that the rotation is in radians
part.change_pose(new_pose, radians = True)

#Verify change
assert part.pose == new_pose
```

At the beginning, we reccomend frequent sanity-checks in the GUI to make sure you got everything right.

> **Note**
> Rotation is stated using **degrees** by default.
> use `part.change_pose(new_pose, radians = True)` for radians.

> **Reminder**
> Changing the pose of a part also changes the poses of every part that is related to its coordinate system.

#### Change part's label

It's possible to change part's label:

```python
part.change_label(new_label)
```

At the beginning, we reccomend frequent sanity-checks in the GUI to make sure you got everything right.

#### Modify Detectors

Detectors have a `detector.change_opacity()` and `detector.change_size()` methods, that changes the detector's opacity and size, accordingly.
This is how you use them:

```python
# Get the detector
detector = setup.get('detector_label')

#Backup the original detector's state
detector.backup()

#Apply changes
detector.change_size([new_half_height, new_half_width])
detector.change_opacity(new_opacity)
detector.change_pose([x, y, z, alpha, beta, gamma])

#Verify change
print(detector.size, detector.opacity, detector.pose)

# Restore if needed
detector.restore()
```

At the beginning, we reccomend frequent sanity-checks in the GUI to make sure you got everything right.

### Modify Light Sources

#### Change wavelengths

Light sources have a `light_source.change_wavelengths()` and `light_source.add_wavelengths()` methods, that changes the light source's wavelengths or add new ones, accordingly.
**In both cases**, you could pass a `list` of equal weight wavelengths or a `dict`, defining wavelength-weight pairs.
This is how you use them:

```python
# Get the light source
light_source = setup['light_source_id']

#Backup the original light source's state
light_source.backup()

#For equal-weight wavelengths
new_wavelengths = [550, 600, 650]
#For non-equal weight wavelengths
new_wavelengths = {550: 0.5, 600: 0.7, 700: 0.3}

#Change the wavelengths completely
light_source.change_wavelengths(new_wavelengths)
#Add new ones
light_source.add_wavelengths(new_wavelengths)

#Change pose
light_source.change_pose([x, y, z, alpha, beta, gamma])

#Verify change
print(light_source.wavelengths, light_source.pose)
```

At the beginning, we reccomend frequent sanity-checks in the GUI to make sure you got everything right.

**Create normal distribution**
If you want to create wavelengths spectrum with a normal disribution, you could use `tdo.utils.wavelengths_normal_distribution`:

```python
import threed_optix.utils as tu

# Define the spectrum
wavelengths_spectrum = tu.wavelengths_normal_distribution(mean_wavelength, std_dev, num_wavelengths)

# Modify the light source
light_source.change_wavelengths(wavelengths_spectrum)
```

Where:

- `mean_wavelength` is the mean wavelength of the distribution
- `std_dev` is the standard deveation of the distribution
- `num_wavelengths` is the number of the wavelengths that will be sampled.

**Create uniform distribution**
Similarly, If you want to create wavelengths spectrum with a uniform disribution, you could use `tdo.utils.wavelengths_uniform_distribution`:

```python
import threed_optix.utils as tu

# Define the spectrum
wavelengths_spectrum = tu.wavelengths_uniform_distribution(min_wavelength, max_wavelength, num_wavelengths)

# Modify the light source
light_source.change_wavelengths(wavelengths_spectrum)
```

Where:

- `min_wavelength` is the minimum wavelength of the distribution
- `max_wavelength` is the maximum wavelength of the distribution
- `num_wavelengths` is the number of the wavelengths that will be sampled.

#### Change to gaussian beam

`ls.to_gaussian()` allows you to change the light source beam to a gaussian and define it.
Arguments:

- `waist_x`: float
- `waist_y`: float
- `waist_position_x`: float
- `waist_position_y`: float

For example:

```python
ls = setup['light_source_id']

gaussian_beam_config = {
    "waist_x": 1,
    "waist_y": 1,
    "waist_position_x": 0,
    "waist_position_y": 0
}

ls.to_gaussian(**gaussian_beam_config)
```

#### Change to plane wave beam

`ls.to_point_source()` allows you to change the light source beam to a point source and define it.
Arguments:

- `density_pattern`: str, one of:
  - "XY_GRID"
  - "CONCENTRIC_CIRCLES"
  - "RANDOM"
- `plane_wave_data`: dict, with the following entries:
  - "source_shape", One of:
    - "RECTANGULAR"
    - "ELLIPTICAL"
    - "CIRCULAR"
  - If type is "RECTANGULAR":
    - "width", "height": floats, the width and height of the beam
  - If type is "CIRCULAR":
    - "radius": float, the radius of the beam
  - If type is "ELLIPTICAL":
    - "radius_x", "radius_y": floats, the radii of the beam.

For example:

```python
plane_wave_data = {
    "source_shape": "RECTANGULAR",
    "width": 10,
    "height": 10
}

plane_wave_config = {
    "density_pattern": "CONCENTRIC_CIRCLES",
    "plane_wave_data": plane_wave_data
}

ls.to_plane_wave(**plane_wave_config)
```

```python

plane_wave_data = {
    "source_shape": "CIRCULAR",
    "radius": 5,
}

plane_wave_config = {
    "plane_wave_data": plane_wave_data,
    "density_pattern": "XY_GRID"
}

ls.to_plane_wave(**plane_wave_config)
```

```python
plane_wave_data = {
    "source_shape": "ELLIPTICAL",
    "radius_x": 7,
    "radius_y": 7
}
plane_wave_config = {
    "plane_wave_data": plane_wave_data,
    "density_pattern": "RANDOM"
}

ls.to_plane_wave(**plane_wave_config)

```

#### Change to point source beam

`ls.to_point_source()` allows you to change the light source beam to a point source and define it.
Arguments:

- `density_pattern`: str, one of:
  - "XY_GRID"
  - "CONCENTRIC_CIRCLES"
  - "RANDOM"
- `model_radius`: float, the model radius between 1 and 10.
- `data`: dict, with the following entries:
  - "type", One of:
    - "HALF_CONE_ANGLE"
    - "HALF_WIDTH_RECT"
    - "HALF_WIDTH_AT_Z"
  - If type is "HALF_WIDTH_AT_Z":
    - "dist_z": float, determine the distance to calculate the half_width_x_at_dist and half_width_y_at_dist
    - "half_width_x_at_dist": float, determines the half width on x-axis at dist_z.
    - "half_width_y_at_dist": float, determines the half width on y-axis at dist_z.
  - Otherwise:
    - "angle_y": float, Y-axis opening angle.
    - "angle_x": float, X-axis opening angle

For example:

```python
ls = setup.get('light_source_label')
point_source_data = {
    "type": "HALF_CONE_ANGLE",
    "angle_y": 10,
    "angle_x": 10
}
point_source_config = {
    "point_source_data": point_source_data,
    "density_pattern": "XY_GRID",
    "model_radius": 1
}
ls.to_point_source(**point_source_config)
```

```python
point_source_data = {
    "type": "HALF_WIDTH_AT_Z",
    "dist_z": 50,
    "half_width_x_at_dist": 10,
    "half_width_y_at_dist": 10,
}
point_source_config = {
    "point_source_data": point_source_data,
    "model_radius": 1,
    "density_pattern": "RANDOM"
}
ls.to_point_source(**point_source_config)
```

#### Change other properties

Other changable properties are:

- `ls.change_power(new_power: float)`: A float between 0 and 1e6.
- `ls.change_rays_direction(phi, theta, azimuth_z)`: The new rays direction parameter
- `ls.change_vis_count(new_vis_count: int)`: An int between 1 and 200, the number of visualization rays in the app.
- `ls.change_vis_count_type(count_type: str)`: "TOTAL" if `ls.vis_count` is the total number of rays. "PER_WAVELENGTH" if it's per wavelengths of the light source.
- `ls.change_opacity(new_opacity: float)`: A float between 0 and 1.
- `ls.change_color(color: str)`: A hexidecimal representation of the visualization color in the GUI.

At the beginning, we reccomend frequent sanity-checks in the GUI to make sure you got everything right.

### Modify Several Properties Together

Changing multiple parameters sequentially can be time consuming.
In order to change several properties together at one time faster, you could use `part.change_config`.
In most cases, the argument are the same arguments of the original method.
In light source source type, it's a dictionary with the arguments and values of the appropriate method.

#### Modify multiple properties of parts

```python
part = setup.get('part_label')
part.change_config(label: str,
                   pose: list[float]
                   )
```

#### Modify multiple properties of detectors

```python
detector = setup['detector_id']
detector.change_config(pose: str,
                      label: str,
                      size: tuple,
                      opacity: float
                      ):
```

#### Modify multiple properties of light sources

```python
light_source = setup['light_source_id']
light_source.change_config(pose: list, #[0, 0, 0, 0, 0, 0,]
                           label: str, #"New label"
                           wavelengths: Union[dict,list], #{550: 0.5, 650: 1}
                           add_wavelengths: Union[dict, list], #{750: 1, 850: 0.5}
                           power: float, #1
                           vis_count: int, #150
                           count_type: str, #TOTAL
                           rays_direction_config: dict, #{'theta': 0, "phi": 0, "azimuth_z": 10}
                           opacity: float, # 0.5
                           color: str, # "#000000"
                           gaussian_beam: dict, #config
                           point_source: dict, #config
                           plane_wave: dict #config
                           ):
```

`gaussian_beam`, `point_source` and `plane_wave` should be the same dictionaries defined in `to_gaussian`, `to_point_source`, and `to_plane_wave`.

> **Reminder**
> For beginners, we reccomend step-by-step changes with frequent sanity-checks in the GUI to make sure you got everything right.

## Run Simulations And Analyses

#### Simulations

Running the simulation is really simple:

```python
#run the simulation
ray_table = setup.run()

#Save the data locally
data_path = 'path/to/save/data.csv'
result.to_csv(data_path)

#View them as pd.DataFrame
results
```

The ray table is a custom `pd.DataFrame` object where each line is a single ray. The columns are:

- **Ox, Oy, Oz**: The origin point of the ray for each axis (mm).
- **Dx, Dy, Dz**: The initial direction of the ray for each axis.
- **Hx, Hy, Hz**: The hit position of the ray for each axis (mm).
- **wavelength**: The wavelength of the ray (nm).
- **Ap**: The p amplitude of the ray.
- **As**: The s amplitude of the ray.
- **phase_s**: The s phase of the ray.
- **phase_p**: The p phase of the ray.
- **refractive_index**: The refractive index of the medium that the ray pass through.
- **diffraction_order**: If the element is gratings, the order of diffraction of the ray. Else- 0.
- **f_s**: Fernel coefficient s.
- **f_p**: Fernel coefficient p.
- **surface**: The index of the surface that the ray hit, when -1 means no hit.
- **light_source**: The index of the light source that generated the ray.
- **parent_idx**: The index of the predecessor ray.
- **family_idx**: The index of the origin ray.

| idx | Ox           | Oy          | Oz          | Dx             | Dy              | Dz           | As          | Ap          | phase_s      |
| --- | ------------ | ----------- | ----------- | -------------- | --------------- | ------------ | ----------- | ----------- | ------------ |
| 105 | 7.383775711  | 102.4612579 | 150.5897217 | -0.02501097694 | -0.008337006904 | 0.9996524453 | 2126145.5   | 2133974.0   | 3.118281841  |
| 114 | -12.16847992 | 92.69891357 | 151.9878235 | 0.04484132305  | 0.02690477483   | 0.9986317754 | 2056683.875 | 2082956.125 | 5.680591106  |
| 173 | 5.660968781  | 103.3965912 | 250.0       | -0.06809257716 | -0.04085547104  | 0.9968421459 | 2481887.5   | 2514519.5   | 1.11269021   |
| 186 | -1.279565811 | 101.2795563 | 250.0000153 | 0.01233130135  | -0.01233132742  | 0.9998478889 | 2591881.75  | 2593823.0   | 1.747841358  |
| 212 | 5.587198734  | 96.64768219 | 251.0799866 | -0.06809251755 | 0.04085548222   | 0.9968422651 | 2481887.5   | 2514519.5   | 0.2006378919 |

| phase_p      | diffraction_order | Hx           | Hy          | Hz          | f_s          | f_p          | refractive_index |
| ------------ | ----------------- | ------------ | ----------- | ----------- | ------------ | ------------ | ---------------- |
| 3.118281841  | 0.0               | 7.148333549  | 102.3827744 | 160.0       | 0.7891492248 | 0.7908383608 | 1.518522382      |
| 5.680591106  | 0.0               | -11.80871105 | 92.91477966 | 160.0       | 0.7770783901 | 0.782813549  | 1.518522382      |
| 1.11269021   | 0.0               | 5.58719492   | 103.3523254 | 251.0800018 | 1.0          | 1.0          | 1.0              |
| 1.747841358  | 0.0               | -1.266245365 | 101.266243  | 251.0800018 | 1.0          | 1.0          | 1.0              |
| 0.2006378919 | 0.0               | 2.182572842  | 98.69045258 | 300.9220886 | 1.0          | 1.0          | 1.0              |

| parent_idx | family_idx | surface     | wavelength | light_source |
| ---------- | ---------- | ----------- | ---------- | ------------ |
| 89.0       | 9.0        | LP86NPVUVMR | 550        | LP86R718Q6B  |
| 66.0       | 18.0       | LP86NPVUVMR | 550        | LP86R718Q6B  |
| 141.0      | 13.0       | LP86PQO2JLV | 550        | LP86R718Q6B  |
| 154.0      | 26.0       | LP86PQO2JLV | 550        | LP86R718Q6B  |
| 164.0      | 4.0        | LP86NPVY4K9 | 550        | LP86R718Q6B  |

### Analyses

#### Run

We can run analysis that is already in `surface.analyses` straight away:

```python
surface = part['surface_id']
analysis = surface.find_analysis(name = "Spot (Coherent Irradiance) Huygens",
                                 rays = {laser: 1e6, laser2: 1e8},
                                 resolution = (400, 400)
                                 )
results = setup.run(analysis)
```

If the analyses that we want is not added yet, we need to add it and then run it.
We have two ways of doing that:

```python
surface.add_analysis(analysis)
results = setup.run(analysis)
```

> **Reminder**
> If you would try to add analysis with exactly the same parameters as one that you already have, you should use **`force = True`** argument to make sure that you are interested with duplicated analysis.
> Otherwise, choose the existing analysis and run it instead. This helps optimizing your system memory credits usage.

If we want to run multiple analyses with the same command, you can do that by passing `tdo.Analysis` list:

```python
analyses = detector_front.analyses[1:4]
results = setup.run(analyses)
```

The recieved results will be a list of results in the same order of the analyses list, so that `results[i]` is the result of analysis `analyses[i]`.

#### Results

Even if we didn't store it in another variable, we can view and analize the latest results in a raw form:

```python
print(analysis.results)
```

The result will be a JSON where each key is a wavelength in the analysis results.
Each wavelength contains the `np.ndarray` matrices for each polarization.
For example, let's say I am looking for the _"X"_ polarization, _400 nm_ rays hit matrix:

```python
matrix = results[400]['X']
```

For analysis without polarization, polarization kind is "NONE".

> **Note**
> If you plan on running the anlysis again, it is really important to store a deepcopy of analysis.results in the `matrix` variable.
> Otherwise, the variable will hold the pointer to the `results` property of the analysis, and the previous results will be overriden.
> Another option is to store the results of `analysis()` in another variable

If we want to see the matrices as a images, we can simply:

```python
#for static figure
analysis.show()

#for interactive figure
analysis.show_interactive()
```

**Arguments**

- **`upscale`**: Upscales the image (smooth the pixels over) by using `upscale = True`.
- **`polarizations`**: Presents only selected polarization by passing a list of strings to the argument.
- **`wavelengths`**: Presents only selected wavelengths by passing a list of integers to the argumet.
- **`figsize`**: Determines the size of the presented figure in inches.

```python
analysis.show(upscale = True,
              polarizations = ['X'],
              wavelengths = [550, 600],
              figsize = (15, 15))
```

```python
analysis.show_interactive(upscale = True,
                          polarizations = ['Y', 'Z'],
                          wavelengths = [500, 700],
                          figsize = (20, 20))
```

## Create a new `tdo.Optics` in your product catalog
As for now, the SDK supports creating spherical and conic lenses.
### Create Lenses
#### Spherical Lenses
To create spherical lens, we can use `client.create_spherical_lens` method. The arguments are:
- `name`: str, the name of the created lens.
- `material`: str, the desired material id of the lens. Can be fetched from name and company with `client.get_material_id` method (**Which is not released yet**).
- `diameter`: float, the diameter of the lens.
- `thickness`: float, the central thickness of the lens.
- `r1`: float, the first radius of curvature.
- `r2`: float, the second radius of curvature.

The method returns the number id of the created element.
```python
lens_number_id = client.create_spherical_lens(name = name,
                                              material = material,
                                              diameter = diameter,
                                              thickness = thickness,
                                              r1 = r1,
                                              r2 = r2)
```

#### Conic Lenses
To create conic lens, we can use `client.create_conic_lens` method. The arguments are:
- `name`: str, the name of the created lens.
- `material`: str, the desired material id of the lens. Can be fetched from name and company with `client.get_material_id` method (**Which is not released yet**).
- `diameter`: float, the diameter of the lens.
- `thickness`: float, the central thickness of the lens.
- `r1_x`, `r1_y`: float, the first radius of curvature.
- `r2_x`, `r2_y`: float, the second radius of curvature.
- `k1_x`, `k1_y`, `k2_x`, `k2_y`: float, the conic coefficients of the lens. Default to 0.

The method returns the number id of the created element.
```python
lens_number_id = client.create_spherical_lens(name = name,
                                              material = material,
                                              diameter = diameter,
                                              thickness = thickness,
                                              r1_x = r1_x,
                                              r1_y = r1_y,
                                              r2_x = r2_x,
                                              r2_y = r2_y,
                                              k1_x = k1_x,
                                              k2_y = k2_y)
```

## Advanced
#### Versioning

We can use the backup options to create versions of the parts and setups and manage them.
In order to do that, we give the back a name:

```python
# For part
part.backup(name = '10z')
# Some code
part.restore(name = '10z')

# For entire setup
setup.backup(name = 'system phase 1')
# Some code
setup.restore(name = 'system phase 1')
```

The same backup name for both setup and part overrides those backups, also for individual parts inside the setup.

#### Ask questions

If you have any questions about the SDK, you can send them to `client.ask()` and get a response from OptiChat, our optics copilot.

```python
client.ask('How can I get the front surface of my detector object?')
```

```python
client.ask('How can I define a uniform distribution between 500 nm and 600 nm to my light source?')
```

```python
client.ask('Why do I need to add the `force` argument when I want to add a duplicated analysis?')
```

### Utils Module

#### Get spot size

`tdo.utils.calculate_spot_size(matrix)` calculates the diameter of the blocking circle of the biggest contours of the matrix, in pixels.

```python
import threed_optix.utils as tu

# Assuming that this analysis exists already
setup = client['setup_id']
detector = setup.get('detector_label')
detector_front = detector.get('front')

analysis = detector_front.analysis_with(name ="Spot (Incoherent Irradiance)",
                                        rays = {laser1: 2.5e6, laser2: 2.5e6},
                                        resolution = (1000, 1000)
                                        )
# Run the analysis
results = setup.run(analysis)

# Calculate spot size
matrix = results[550]['X']
spot_size_dia = tu.calculate_spot_size(matrix)
print(f'Analysis spot size diameter for X polarization at 550 nm is {spot_size_dia}')
```

`tdo.utils.encircled_energy(matrix, percent)` calculates the diameter of the circle that centers at the center of energy mass, and contains `percantage` of the matrix's total energy.

```python
encircled_energy_radius, center = tu.encircled_energy(matrix, 0.9)
print(f'Analysis encircled 90% energy radius for X polarization at 550 nm is {encircled_energy_radius}')
```

Of course, these values are pixel values. In order to get absolute values, use `tdo.utils.absolute_pixel_size`:

```python
pixel_radius, center = tu.encircled_energy(matrix, 0.95)
absolute_radius = tu.absolute_pixel_size(detector.size, analysis.resolution)[0] * pixel_radius
print(f'95% Encircled energy radius is {absolute_radius} mm')
```

#### Scans

In order to perform a scan, all we need to do is to define an analysis:

```python
analysis = tdo.Analysis(name = "Spot (Incoherent Irradiance)",
                        rays = {light_source: 1e5, light_source2: 5e4}
                        resolution = [500, 500],
                        surface = detector.get('front')
                        )
detector_front.add_analysis(analysis)
```

Or choosing an existing one:

```python
analysis = detector_front.analysis_with(name = name,
                                        rays = rays,
                                        resolution = resolution
                                        )
assert analysis is not None
```

Then, we need to iteratively change the properties of some part in the setup and store the results.

```python
def scan_z(lens, analysis, dz_range, steps):

    # Make a backup
    lens.backup()

    # Store the original pose
    original_pose = lens.pose.copy()

    # Store the results here
    results_history = []

    # Iterate over the lens location in the z axis
    for dz in np.arange(dz_range[0], dz_range[1] + steps, steps):

        #Define absolute new pose
        delta = [0, 0, dz, 0, 0, 0]
        new_pose = [j+h for j, h in zip(original_pose, delta)]

        # Apply changes
        lens.change_pose(new_pose)

        # Run analysis
        results = setup.run(analysis)
        results = {"dz": dz, "results": results}

        # Store them
        results_history.append(results)

    lens.restore()

    return results_history

results = scan_z(lens, analysis, (-1, 1), 0.1)
```

Similarly, you will be able to perform grid scan, changing multiple parameters together.
If we want, we can always store a version of the system at each iteration and restore it afterwards:

```python
for i, config in lens_configs:
    lens.change_config(**config)
    lens.backup(name = i)
    results = setup.run(analysis)
    results_history.append(results)

# Choose the best version
lens.restore(name = best_i)
```

#### Optimizations

If you have a merit or loss function you wish to optimize or minimize, consider using `tdo.optimize`.
Here are few examples:

```python
import threed_optix.optimize as opt
import threed_optix.utils as tu

def loss(new_yz):

    # Define absolute new pose
    y, z = new_yz
    new_pose = original_pose.copy()
    new_pose[1] = y
    new_pose[2] = z

    # Change len's pose
    lens.change_pose(new_pose)

    # Run analysis
    results = setup.run(analysis)

    # Get the right image
    image = results[550]['X']

    # Calculate spot size can be any function that returns a scalar you want to minimize.
    spot_size_diameter = tu.calculate_spot_size(image)

    print(f"dz: {dz}, dy: {dy}, spot size: {spot_size_diameter}")
    return spot_size_diameter

# Assuming the initial guess is the current lens position
lens = setup.get('lens1')
lens.backup(name = 'before_optimization')
original_pose = lens.pose.copy()

y = original_pose[1]
z = original_pose[2]
initial_guess = [y, z]

# Define bounds to avoid getting out of desired range
low_y = y -1
high_y = y + 1
low_z = z -1
high_z = z + 1
bounds = [(low_y, high_y), (low_z, high_z)]

# Execute search
result = opt.minimize(loss, initial_guess, method='Nelder-Mead', bounds = bounds)

# Restore backup values
lens.restore(name = 'before_optimization')

# Get the optimized values
best_y, best_z = result.x

# Output the best values found
print(f"Best z: {best_z}, Best y: {best_y}")

best_pose = original_pose.copy()
best_pose[1] = best_y
best_pose[2] = best_z
lens.change_pose(best_pose)
```

Ofcourse, the optimization will be done up to the point where there is no change in the pixel radius.
In order to bypass this, you could make the detector smaller and smaller as the iteration goes.
If you do so, you should return the absolute value, rather then pixel one.

```python
detector = setup['detector_id']
lens = setup['lens_id']

def loss(new_yz):
    y, z = new_yz
    new_pose = detector.pose
    new_pose[1] = y
    new_pose[2] = z
    # Change len's pose
    lens.change_pose(new_pose)
    # Run analysis
    results = setup.run(analysis)
    # Get the right image
    image = results[550]['X']
    # Calculate spot size can be any function that returns a scalar you want to minimize.
    pixel_radius, center = tu.encircled_energy(image, percent = 0.9)
    # Assuming your resolution and detector size are squares. Otherwise, ofcourse, further modifications are required.
    absolute_radius = tu.absolute_pixel_size(detector.size, analysis.resolution)[0] * pixel_radius
    # Make the detector smaller to increase optimization accuracy
    size = max(min(absolute_radius * 5, 200), minimum_search_size)
    detector.change_size([size, size])

    return absolute_radius
```

#### Matlab code

If you have a **general** matlab code you wish to use in our SDK, you could simply translate it to python. We recommend using `matlab2python` library from the github repo:

```bash
pip install matlab2python@git+https://github.com/ebranlard/matlab2python.git#egg=m
atlab2python
```

And then use in your code:

```python
import matlabparser as mpars

mlines="""# a comment
x = linspace(0,1,100);
y = cos(x) + x**2;
"""
pylines = mpars.matlablines2python(mlines, output='stdout')
print(pylines)
```

> **Warning**
> This is an external library that's not part of our SDK, nor was built by us.
> Use output code with caution.

#### Send Feedback

We would love to hear you feedback and suggestions.
Please send any thoughts and ideas you have to us by using `client.feedback()` method.

```python
client.feedback('Thanks for reading so far!')
```

# License

3DOptix API is available with [MIT License](https://choosealicense.com/licenses/mit/).
