Using Downwelling Light Sensor Data for Irradiance

The field looks healthy in the north half of the orthomosaic and stressed in the south. Nothing changes at the boundary on the ground — no soil type change, no irrigation zone, no management difference. What changed is that a cloud passed over halfway through the flight, and the reflectance values were calibrated from a panel photographed before take-off under full sun.

An index built from uncalibrated or panel-only reflectance under variable light is, in part, a map of the weather during the flight. The downwelling light sensor exists to remove that, and using it well is more involved than dividing by a number.

This page covers reading the record, the attitude correction that makes it usable, smoothing it without erasing what matters, and deciding when it should be trusted over the panel. It completes the calibration chain in radiometric calibration in Python.

What the sensor measures, and what it does not

A downwelling light sensor is a small upward-facing spectrometer or set of filtered photodiodes on the airframe, sampling incident irradiance in the same bands as the camera, once per frame. Dividing the measured radiance by that irradiance gives a quantity proportional to reflectance, independent of how much light there was.

Two complications stop it from being a straight division.

Attitude. The sensor measures irradiance on its own plane, which tilts with the aircraft. A bank of 15° during a turn changes the cosine of the incidence angle by several percent, and at low sun angles by much more. Without an attitude correction, the irradiance record contains the flight pattern, and dividing by it stamps the flight pattern into the reflectance.

Diffuse versus direct. On a clear day most irradiance is direct sunlight arriving from one direction, and the cosine correction is nearly exact. Under overcast it is nearly all diffuse, arriving from the whole sky, and tilt barely matters. The correct correction is a weighted blend, and the weight depends on conditions the sensor only partly observes.

Aircraft attitude entering the irradiance record A flight path drawn as four parallel lines with turns between them. Beneath it, the raw irradiance record shows regular dips coinciding with each turn, where the aircraft banked and the sensor plane tilted away from the sun. The attitude-corrected record is flat across the same turns, retaining only a genuine dip in the middle of the flight where a cloud passed. A note states that an uncorrected record stamps the flight pattern into the reflectance, producing stripes that follow the flight lines rather than the ground. flight path — four lines, three turns raw — dips at every turn attitude-corrected — one real cloud An uncorrected record contains the flight, not only the sky. Dividing by it produces stripes that follow the flight lines rather than the ground.

Figure 1 — The turns are visible in the raw record because the sensor tilted, not because the light changed.

Minimal reproducible solution

import numpy as np


def correct_irradiance_for_attitude(raw: np.ndarray, roll_deg: np.ndarray,
                                    pitch_deg: np.ndarray, solar_zenith_deg: float,
                                    solar_azimuth_deg: float, yaw_deg: np.ndarray,
                                    *, direct_fraction: float = 0.7) -> np.ndarray:
    """Convert sensor-plane irradiance to horizontal-plane irradiance.

    The direct component follows the cosine of the angle between the sensor
    normal and the sun; the diffuse component is treated as isotropic and
    essentially unaffected by tilt. The blend weight is the direct fraction,
    which is close to 0.85 under clear sky and near 0.1 under thick overcast.
    """
    roll = np.radians(roll_deg)
    pitch = np.radians(pitch_deg)
    yaw = np.radians(yaw_deg)
    sz = np.radians(solar_zenith_deg)
    sa = np.radians(solar_azimuth_deg)

    # Sensor normal in the local horizontal frame.
    nx = np.sin(pitch) * np.cos(yaw) + np.sin(roll) * np.sin(yaw)
    ny = np.sin(pitch) * np.sin(yaw) - np.sin(roll) * np.cos(yaw)
    nz = np.cos(roll) * np.cos(pitch)

    # Unit vector toward the sun.
    sx = np.sin(sz) * np.cos(sa)
    sy = np.sin(sz) * np.sin(sa)
    sz_ = np.cos(sz)

    cos_sensor = np.clip(nx * sx + ny * sy + nz * sz_, 1e-3, 1.0)
    cos_horizontal = max(np.cos(sz), 1e-3)

    direct = direct_fraction * cos_horizontal / cos_sensor
    diffuse = (1.0 - direct_fraction)
    return raw * (direct + diffuse)

The clipping of cos_sensor at a small positive value is not cosmetic: during an aggressive turn the term can approach zero, and an unclipped division produces an irradiance spike that then divides into the reflectance and produces a bright stripe.

Estimating the direct fraction is the remaining judgement. The pragmatic approach is to derive it from the record’s own variability — a clear-sky flight has a smooth, slowly varying irradiance, while an overcast one is flat and a broken-cloud one is spiky.

import numpy as np


def estimate_direct_fraction(raw: np.ndarray, clear_sky_model: np.ndarray) -> float:
    """Direct fraction from how close the record sits to a clear-sky expectation.

    A flight at 90 % of the clear-sky value is mostly direct; one at 30 % is
    heavily overcast and therefore mostly diffuse. The mapping is approximate
    and is far better than assuming a constant.
    """
    ratio = float(np.median(raw / np.maximum(clear_sky_model, 1e-9)))
    return float(np.clip(1.15 * ratio - 0.15, 0.05, 0.9))

Smoothing without erasing the signal

The raw record is noisy at the per-frame level, and the temptation is to smooth it heavily. That is a mistake: the whole value of the sensor is that it captures fast changes, and a filter wide enough to remove the noise also removes the cloud edge the correction exists for.

import numpy as np


def smooth_irradiance(values: np.ndarray, times_s: np.ndarray,
                      *, window_s: float = 2.0) -> np.ndarray:
    """Median filter over a short time window, preserving genuine steps.

    A median over about two seconds removes single-frame noise while leaving
    a cloud edge — which crosses the field of view in a fraction of a second
    — essentially intact. A mean over the same window rounds the edge off.
    """
    out = np.empty_like(values, dtype=np.float64)
    for i, t in enumerate(times_s):
        sel = np.abs(times_s - t) <= window_s / 2
        out[i] = np.median(values[sel])
    return out

A median rather than a mean is the key choice. The signal being preserved is a step, and medians preserve steps while means blur them.

Where a downwelling light sensor misreads, and by how much Three rows. Aircraft attitude tilts the sensor away from horizontal, and its cosine response means a ten degree bank under a low sun can bias the reading by several per cent unless the attitude is applied as a correction. A low sun elevation magnifies every attitude error, because the cosine response is steepest there, which is why early and late flights carry the largest irradiance uncertainty. Shading by the airframe, propeller or an antenna produces brief dips that correlate with heading rather than with light, and these are visible as a periodic signature in the irradiance series. aircraft attitude cosine response: a 10° bank under a low sun biases by several per cent low sun elevation magnifies every attitude error — early and late flights carry the most airframe shading brief dips correlated with heading, visible as a periodic signature All three are correctable from telemetry the flight already recorded.

Figure 3 — Three biases, each recoverable from the flight log.

Edge-case matrix

Situation Symptom Handling
No attitude correction Stripes following flight lines Apply the cosine blend
Aggressive turns Irradiance spikes Clip the cosine term; consider discarding turn frames
Clear sky Correction is nearly pure cosine Direct fraction near 0.85
Thick overcast Tilt barely matters Direct fraction near 0.1
Sensor obstructed by a limb Sudden drop, unrelated to sky Detect as an outlier against neighbours
Sensor reading zero Division by zero Fail the frame rather than substituting
Record not time-aligned to frames Wrong irradiance per frame Match on timestamp, and verify the offset
Sensor and camera bands differ Wrong band’s irradiance applied Match by centre wavelength, not by index

The band-matching row catches people with mixed hardware. The sensor’s channels and the camera’s bands are usually in the same order and occasionally are not; matching by nominal centre wavelength rather than by array position removes the risk.

Verification snippet

import numpy as np


def verify_irradiance_correction(reflectance_by_frame: dict[int, float],
                                 irradiance_by_frame: dict[int, float]) -> dict:
    """Calibrated reflectance of a fixed target must not track the irradiance.

    Sample the same permanent surface in many frames taken under different
    light. If the calibration worked, its reflectance is flat; if it did not,
    reflectance correlates with irradiance, and the correlation coefficient
    says by how much.
    """
    frames = sorted(set(reflectance_by_frame) & set(irradiance_by_frame))
    r = np.array([reflectance_by_frame[f] for f in frames])
    e = np.array([irradiance_by_frame[f] for f in frames])
    if r.size < 12:
        return {"note": "too few common frames to judge"}

    corr = float(np.corrcoef(r, e)[0, 1])
    return {"frames": int(r.size), "correlation": corr,
            "reflectance_spread": float(r.max() - r.min()),
            "ok": abs(corr) < 0.3,
            "note": ("irradiance is correctly divided out" if abs(corr) < 0.3
                     else "reflectance still tracks the light — check the "
                          "attitude correction and the band matching")}

This is the test worth automating, because it needs nothing that a flight does not already have: any permanent surface visible in enough frames will do, and the correlation coefficient is a single interpretable number.

Reflectance of a fixed target against measured irradiance Two scatter plots of a permanent target's calibrated reflectance against the irradiance measured when each frame was taken. On the left, without the irradiance correction, the points lie on a rising line with a correlation of zero point eight nine, showing that reflectance still tracks the light. On the right, after correction, the points form a flat cloud with a correlation of zero point zero six and a spread of about one percent. A note states that the test needs only a permanent surface visible in enough frames. panel only — r = 0.89 with irradiance — r = 0.06 irradiance → irradiance → A permanent surface in enough frames is all the test needs. One correlation coefficient says whether the light has been divided out.

Figure 2 — The single most informative check available on a calibrated multispectral flight.

When to trust the sensor over the panel

Both are measurements of the same physical quantity with different error characteristics, so the question is which to weight.

Under stable light the panel is more accurate: it is a direct comparison against a certified surface under the same illumination, with no attitude term and no spectral mismatch. Use the panel, and use the irradiance record only to confirm that the light really was stable.

Under variable light the sensor is the only option that can track the variation, so it carries the per-frame shape while the panel sets the absolute level. That combination — panel for scale, sensor for variation — is the standard treatment and is what the reflectance function in the parent page implements.

Where the two disagree systematically, the usual causes are a band mismatch or an attitude correction with the wrong direct fraction, and both are worth resolving rather than picking a winner.

When to escalate

  • The correction makes the mosaic worse. Check the band matching and the timestamp alignment before adjusting the physics. Applying the wrong band’s irradiance is a common and confusing failure.
  • The sensor was obstructed for part of the flight. Those frames have no usable irradiance. Interpolating across a short gap is reasonable; across several minutes it is not, and the affected area should be re-flown.
  • Index values still track cloud cover after correction. The residual is often shadow geometry rather than irradiance — canopy self-shadowing changes with the diffuse fraction, and no radiometric correction addresses it. Flying under consistent conditions is the only reliable remedy.

Radiometric Calibration in Python