# Terrain and Solar Radiation Guide

This guide covers all GRASS GIS / Actinia processing stages: terrain preparation (DSM import, smoothing, slope/aspect, horizon analysis) and solar radiation calculation with `r.sun`.

## Contents

- [Terrain and solar-radiation workflow](#terrain-and-solar-radiation-workflow)
- [Workflow endpoints](#workflow-endpoints)
- [Two ways to start](#two-ways-to-start)
- [Smooth, slope, and aspect](#smooth-slope-and-aspect)
- [Horizon analysis](#horizon-analysis)
- [Solar radiation (r.sun)](#solar-radiation-rsun)
- [Required order](#required-order)
- [cURL examples](#curl-examples)
- [Input guidance](#input-guidance)
- [Common problems](#common-problems)

---

## Terrain and Solar-Radiation Workflow

The following diagram shows the end-to-end terrain and solar-radiation workflow, from terrain acquisition to `r.sun`, together with the main session-validation and persistence steps between stages.

![Terrain and solar-radiation workflow](diagrams/solar-radiation-pipeline-diagram.png)

*Figure 1: Sequence view of the terrain and solar-radiation pipeline, including real/synthetic DSM entry paths, DSM smoothing, horizon analysis, and solar-radiation processing.*

In this workflow, the client calls the `/solar` endpoints stage by stage. Each request is validated against the active backend session, the processing step is executed through the solar service layer and Actinia/GRASS, and the resulting workflow context is persisted so the next stage can resolve its prerequisites.

---

## Workflow Endpoints

All terrain and solar-radiation endpoints covered in this guide are available under `/solar`.

### Terrain Endpoints

#### Create or Import Terrain

- `POST /solar/upload-real-dsm`
- `POST /solar/upload-and-synthetic-dsm`

#### Derive Terrain Products

- `POST /solar/dsm-smooth-and-slope-aspect`
- `POST /solar/horizon`

### Solar Radiation Endpoint

- `POST /solar/solar-radiation`

---

## Two Ways to Start

The terrain workflow starts from two main scenarios.

### 1. A real DSM already exists

Use:

```text
POST /solar/upload-real-dsm
```

Choose this path when the user already has a real DSM with `1 m` resolution or finer and wants to upload it directly together with the building-footprint layer.

Main inputs:

- `dsm_file_input`
- `buildings_file_input`
- optional `epsg`

After this stage, the workflow continues with DSM smoothing and slope/aspect generation.

### 2. A real DSM does not exist

Use:

```text
POST /solar/upload-and-synthetic-dsm
```

Choose this path when no final real DSM is available and the system must first build a synthetic DSM.

This synthetic DSM workflow supports two input modes:

#### 2.1. Building footprints already contain a height column

In this mode, the uploaded building-footprint vector already contains building heights, so the synthetic DSM can be built directly from vector geometry plus the height attribute.

Main inputs:

- `buildings_input`
- optional `epsg`
- `height_source_col`
- `slope_min_deg`
- `slope_max_deg`
- `step_m`
- `res_m`

Parameter notes:

- `slope_min_deg` and `slope_max_deg` define the lower and upper bounds of the synthetic roof-slope values assigned during DSM generation. In the implementation, valid bounds must satisfy `0 <= slope_min_deg < slope_max_deg <= 90`.
- `step_m` is the progressive buffer step requested in meters while expanding outward from building footprints during synthetic DSM construction. If the active GRASS location uses a projected metric CRS, this value is used directly. If the location uses an angular CRS, the workflow first computes the mean latitude of the current region and converts `step_m` into an equivalent angular distance in degrees before running the buffer steps. In the implementation, that converted degree step is also clamped to a small minimum value to avoid numerical collapse for extremely small steps.
- `res_m` is the target raster resolution requested in meters for the generated DSM and for the GRASS region used during alignment, rasterization, and interpolation steps. In projected metric CRS, this target resolution can be used directly. In angular CRS, the workflow first converts `res_m` into separate north-south and east-west region resolutions (`nsres` and `ewres`) using the mean latitude of the current region, so the resulting grid still approximates the requested ground resolution in meters.

For angular CRS, the key conversions are:

$$
\phi = \frac{\text{north} + \text{south}}{2}
$$

$$
d_{\mathrm{lat}} = \frac{\mathrm{step}_m}{M_{\mathrm{per-deg}}}
$$

$$
d_{\mathrm{lon}} = \frac{\mathrm{step}_m}{M_{\mathrm{per-deg}} \cos(\phi)}
$$

$$
\mathrm{step}_{\deg} = \frac{d_{\mathrm{lat}} + d_{\mathrm{lon}}}{2}
$$

$$
\mathrm{nsres} = \frac{\mathrm{res}_m}{M_{\mathrm{per-deg}}}
$$

$$
\mathrm{ewres} = \frac{\mathrm{res}_m}{M_{\mathrm{per-deg}} \cos(\phi)}
$$

$$
\phi = \text{mean latitude of the current GRASS region}, \qquad
M_{\text{per-deg}} = \text{meters-per-degree conversion constant used by the workflow}
$$

The preflight stage also estimates the total spatial extent to cover and, from that, the number of progressive buffer iterations:

$$
\mathrm{latRange}_m = \left| \text{north} - \text{south} \right| \cdot M_{\mathrm{per-deg}}
$$

$$
\mathrm{lonRange}_m = \left| \text{east} - \text{west} \right| \cdot M_{\mathrm{per-deg}} \cos(\phi)
$$

$$
\mathrm{maxDistance}_m = \max(\mathrm{latRange}_m, \mathrm{lonRange}_m)
$$

$$
n_{\mathrm{steps}} = \left\lceil \frac{\mathrm{maxDistance}_m}{\mathrm{step}_m} \right\rceil
$$

Once the working region resolution has been fixed, the raster cell area can be interpreted as:

$$
\mathrm{cellArea} = \mathrm{cellHeight}_m \cdot \mathrm{cellWidth}_m
$$

In projected metric CRS, this reduces to:

$$
\mathrm{cellArea} = \mathrm{nsres} \cdot \mathrm{ewres}
$$

In angular CRS, `cell_height_m` and `cell_width_m` are first derived from the latitude-aware metric interpretation of `nsres` and `ewres`.

#### 2.2. Building footprints do not contain a height column

In this mode, the footprint layer has no building-height attribute. The system therefore first derives building heights from a coarser DSM and DTM, and then uses those derived heights to build the synthetic DSM.

Main inputs:

- `buildings_input`
- optional `epsg`
- `dsm_input`
- `dtm_input`
- `slope_min_deg`
- `slope_max_deg`
- `step_m`
- `res_m`

Parameter notes:

- `dsm_input` is the source DSM raster used only for height derivation in the synthetic-DSM workflow when no building-height column is available. In practice, this input is expected to be a coarser raster source rather than the final `1 m` DSM used in the real-DSM path.
- `dtm_input` is the matching terrain model used together with `dsm_input` so that building heights can be estimated from `DSM - DTM`. It should describe the same area as the DSM source.
- The backend does not require `dsm_input` and `dtm_input` to arrive with exactly the same native raster resolution. In the current implementation, the working GRASS region is aligned to the DSM raster and the DTM is resampled to that aligned working grid with `r.resamp.interp` before the normalized DSM (`nDSM`) is computed. Even so, the two rasters should be compatible source datasets for the same scene, and in practice they will usually come from the same coarse-resolution product family (for example, both `10 m`).
- `slope_min_deg` and `slope_max_deg` again define the allowed slope range used when assigning synthetic roof-slope values.
- `step_m` controls the requested meter-based spacing of the progressive buffer rings used to construct the synthetic surface. In angular CRS, the code converts this meter value into a latitude-aware degree step before the buffer iterations are run.
- `res_m` sets the target output resolution of the generated DSM and the working GRASS region. If the location is degree-based, the code converts this meter-based target into latitude-aware `nsres` and `ewres` values before the aligned raster workflow is executed.

The same angular-CRS conversions described in Section 2.1 apply here as well for `step_m` and `res_m`.

After either synthetic-DSM mode completes, the workflow converges on the same next stage: DSM smoothing followed by slope/aspect generation.

---

## Smooth, Slope, and Aspect

After either terrain-import workflow succeeds, the usual next step is:

```text
POST /solar/dsm-smooth-and-slope-aspect
```

This stage derives:

- smoothed DSM
- slope raster
- aspect raster

These outputs are prerequisites for horizon analysis and `r.sun`.

Notes:

- `slope` is produced in degrees and, in this workflow, is the quantity for which explicit numeric bounds are introduced earlier in the synthetic-DSM stage (`0 <= slope_min_deg < slope_max_deg <= 90`).
- `aspect` is not a user-supplied parameter in this stage and therefore no input validation range is defined for it here. It is derived automatically by `r.slope.aspect` in `degrees` format as a GRASS-style aspect raster. When the fast horizon PV branch passes this value to pvlib as `surface_azimuth`, it converts the GRASS convention to pvlib convention with `pvlib_azimuth = (90 - grass_angle) % 360`.
- When aspect ranges are needed later, they are introduced in the suitable-area filtering stage through parameters such as `aspect_min` and `aspect_max` rather than in the terrain-generation stage itself.

---

## Horizon Analysis

Once the smoothed DSM exists, run:

```text
POST /solar/horizon
```

Main inputs:

- `step`
- `maxdistance_m`
- `distance`
- `nprocs`

Parameter notes:

- `step` corresponds to the `r.horizon` angular step size in degrees. It controls the spacing between sampled horizon directions. For example, `20` means the horizon is evaluated every 20 degrees around the full azimuth range.
- `maxdistance_m` is the wrapper parameter exposed by this backend for the `r.horizon maxdistance` setting. The user provides it in meters. In projected metric CRS, the value is passed through directly. In angular CRS, the code converts it into degree-based map units using the mean latitude of the current region before calling `r.horizon`:

$$
\mathrm{maxdistance}_{\deg} = \frac{\mathrm{maxdistance}_m}{M_{\mathrm{per-deg}} \cos(\phi)}
$$
- `distance` corresponds to the `r.horizon` sampling-distance step coefficient. According to the GRASS interface, it is expected in the range `0.5-1.5`; smaller values increase sampling density, while larger values reduce it.
- `nprocs` is not a native `r.horizon` scientific parameter; it is the backend wrapper parameter for parallel execution, controlling how many worker processes are requested for the GRASS run inside the container.

This creates horizon outputs used by the `r.sun` solar radiation calculation.

### Azimuth Convention Handling

GRASS `r.horizon` and pvlib/PVGIS use different azimuth conventions. The `r.horizon` raster suffixes are stored in the GRASS directional convention, where `0°` corresponds to East, `90°` to North, `180°` to West, and `270°` to South, increasing counterclockwise from East. pvlib and PVGIS use azimuths clockwise from North, where South is `180°`.

Any workflow that compares local `r.horizon` outputs with PVGIS horizons or pvlib solar azimuths must first convert the GRASS direction:

```text
pvlib_azimuth = (90 - grass_angle) % 360
```

The fast horizon PV workflow applies this conversion before merging local DSM-derived horizons with PVGIS horizons and before querying the merged horizon profile using pvlib solar azimuth.

---

## Solar Radiation (r.sun)

Use:

```text
POST /solar/solar-radiation
```

Purpose:

- run GRASS `r.sun` on the prepared terrain inputs (smoothed DSM, slope, aspect, horizon) via Actinia
- produce hourly and daily irradiance rasters used in later simulation stages

Main inputs:

- `horizon_step`
- `albedo_value`
- `nprocs`
- `linke_values_str`

### Parameter Notes

- `horizon_step` corresponds to the `r.sun horizon_step` parameter. It should match the angular step used when the horizon rasters were generated in the previous workflow stage. If the requested value differs from the cached horizon context, the backend resolves and uses the stored horizon-step value for the current session.
- `albedo_value` corresponds to the `r.sun albedo_value` parameter. It provides a single scalar ground-albedo coefficient for the run, rather than an albedo raster map.
- `nprocs` is the requested parallelism for the `r.sun` run. At the API layer it is validated to stay within the supported range `1-14`.
- `linke_values_str` corresponds to a month-by-month `linke_value` series for the `r.sun` runs. The backend parses this string into 12 numeric Linke turbidity values, one for each representative day-of-year used by the workflow.

### Internally Resolved Inputs

- `elevation` is not provided by the client at this stage. The backend resolves it from the session context as the previously generated smoothed DSM raster.
- `slope` is also resolved from the session context as the slope raster generated by the smoothing stage.
- `aspect` is resolved from the session context as the previously generated aspect raster.
- `horizon_basename` is resolved from the cached horizon-analysis context rather than being supplied by the client.
- `beam_rad` and `diff_rad` are output raster names generated automatically by the runner for each `(day, time)` combination. This wrapper does not currently expose `glob_rad`, `refl_rad`, `insol_time`, `civil_time`, `distance_step`, or other optional `r.sun` parameters from the full GRASS interface.

### Execution Schedule

`day` and `time` are not user inputs in this endpoint. The backend runs `r.sun` over a predefined schedule of representative days and times.

Representative `day` schedule used by the backend:

| Month | Jan | Feb | Mar | Apr | May | Jun | Jul | Aug | Sep | Oct | Nov | Dec |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Date | 17th | 16th | 16th | 15th | 15th | 11th | 17th | 16th | 15th | 15th | 14th | 10th |
| DOY | 17 | 47 | 75 | 105 | 135 | 162 | 198 | 228 | 258 | 288 | 318 | 344 |

For each representative day, the runner evaluates `r.sun` every `15` minutes from `04:00` through `22:00`, which gives `73` time steps per day.

### Parallel Execution

Inside the runner, the effective parallelism is derived by reserving two CPU cores for the system and clamping the request to the remaining usable capacity:

$$
\mathrm{usable} = \max(1,\ \mathrm{cpuCount} - 2)
$$

$$
n_{\mathrm{procs,effective}} = \min(n_{\mathrm{procs,requested}},\ \mathrm{usable})
$$

After that, the runner chooses a time-chunk size automatically to balance scheduling overhead against load balancing:

$$
\mathrm{chunkSize} =
\begin{cases}
16, & n_{\mathrm{procs,effective}} \leq 3 \\
12, & 4 \leq n_{\mathrm{procs,effective}} \leq 6 \\
8, & 7 \leq n_{\mathrm{procs,effective}} \leq 10 \\
4, & n_{\mathrm{procs,effective}} > 10
\end{cases}
$$

This means the backend does not simply use the user-provided `nprocs` as-is. It first chooses a safe effective worker count from the available CPUs, and then chooses a chunk size that fits that worker count. In practice, the most suitable requested value is usually close to the number of CPU cores you are willing to devote to the run, up to the API limit. Requesting a much larger value does not increase parallelism once the `cpu_count - 2` clamp has been reached.

The main idea is therefore:

- fewer workers -> larger chunks -> lower scheduling overhead
- more workers -> smaller chunks -> better load balancing across workers

Important rule:

- `linke_values_str` must provide exactly 12 monthly Linke turbidity values

Example:

```text
3.5,4.3,4.0,4.2,4.6,4.6,4.4,4.5,4.3,4.0,4.4,4.4
```

The outputs of this stage are the starting point for the simulation inputs pipeline described in [simulation_inputs.md](./simulation_inputs.md).

---

## Required Order

### Real DSM Path

1. `upload-real-dsm`
2. `dsm-smooth-and-slope-aspect`
3. `horizon`
4. `solar-radiation`

### Synthetic DSM Path

1. `upload-and-synthetic-dsm`
2. `dsm-smooth-and-slope-aspect`
3. `horizon`
4. `solar-radiation`

---

## cURL Examples

### Synthetic DSM: Height From Vector Attribute

```bash
curl -X POST "http://localhost:8000/solar/upload-and-synthetic-dsm" \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Accept: application/json" \
  -F "buildings_input=@/path/to/buildings.gpkg" \
  -F "height_source_col=HEIGHT" \
  -F "epsg=32632" \
  -F "slope_min_deg=23" \
  -F "slope_max_deg=30" \
  -F "step_m=1" \
  -F "res_m=1"
```

### Synthetic DSM: Height Derived from DSM and DTM

```bash
curl -X POST "http://localhost:8000/solar/upload-and-synthetic-dsm" \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Accept: application/json" \
  -F "buildings_input=@/path/to/buildings.gpkg" \
  -F "dsm_input=@/path/to/dsm.tif" \
  -F "dtm_input=@/path/to/dtm.tif" \
  -F "epsg=32632" \
  -F "step_m=1" \
  -F "res_m=1"
```

### Real DSM Import

```bash
curl -X POST "http://localhost:8000/solar/upload-real-dsm" \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Accept: application/json" \
  -F "dsm_file_input=@/path/to/dsm.tif" \
  -F "buildings_file_input=@/path/to/buildings.gpkg" \
  -F "epsg=32632"
```

If EPSG override is omitted, the service attempts autodetection:

```bash
curl -X POST "http://localhost:8000/solar/upload-real-dsm" \
  -H "Authorization: Bearer <ACCESS_TOKEN>" \
  -H "Accept: application/json" \
  -F "dsm_file_input=@/path/to/dsm.tif" \
  -F "buildings_file_input=@/path/to/buildings.gpkg"
```

---

## Input Guidance

### EPSG

If provided, `epsg` must be an integer such as:

```text
32632
```

### Height Source Column

For the synthetic DSM workflow:

- if `height_source_col` is provided, heights come from the building vector attribute
- if `height_source_col` is omitted, heights can be derived from `DSM - DTM` when both rasters are available

### Slope Range

For the synthetic DSM workflow:

- `0 <= slope_min_deg < slope_max_deg <= 90`

### Resolution

- `step_m` must be greater than `0`
- `res_m` must be greater than `0`

---

## Common Problems

### `invalid_epsg`

Cause:

- the EPSG value was not a valid integer

### `user_not_found`

Cause:

- the authenticated session resolved to a missing user row

### Missing Terrain Prerequisites

Cause:

- you tried to run smoothing or horizon before importing or generating the required DSM inputs

### `invalid_linke_values`

Cause:

- the monthly Linke turbidity list does not contain exactly 12 values

### `owner_missing`

Cause:

- the logged-in user could not be mapped to an Actinia owner
