1. System Overview

SoilIQ is a native iOS app that delivers real-time soil temperature at four measurement depths, a 14-day forecast, hourly resolution charts, growing degree day tracking, and a 133-crop planting intelligence engine. The app is built entirely in SwiftUI using an @Observable data store, with no third-party analytics or advertising frameworks in the data path.

The core intelligence pipeline runs as follows:

1

Location Resolution

CoreLocation provides GPS coordinates. A freshness guard and spatial radius check determine whether a network fetch is actually needed or whether the on-disk cache is still valid.

2

Parallel API Fetch

Three concurrent async tasks: Open-Meteo forecast (hourly + daily, 17 days), ERA5-Land historical archive (12 monthly means), and ISRIC SoilGrids v2.0 (soil texture fractions). Each has its own cache layer with independent TTL and spatial radius.

3

Raw Parse → Daily Aggregation

Hourly soil_temperature_* values are mean-aggregated to daily readings. The precipitation cooling model is applied per-depth to each daily reading.

4

Soil Thermal Amplitude Correction

SoilGrids texture data (sand + clay fractions) feeds the thermal amplitude correction model. The hourly deviation from daily mean is scaled by a depth-attenuated factor derived from the Johansen thermal conductivity relationship.

5

NWP Artifact Guard

The 14-day forecast is scanned for uniform-value artifacts — a known NWP model boundary artifact where model time-step transitions produce implausibly flat temperature sequences. Detected artifacts trigger per-day gradient synthesis.

6

Probe Calibration Offset

Per-depth user-measured calibration offsets are applied at render time (not stored in the reading). This preserves the separation between model output and user correction.

7

PlantAI + GDD + Health Score

All 133 crops are evaluated against current soil conditions, forecast trajectory, and GDD accumulation to produce a sorted, bucketed planting recommendation list.

2. Data Sources

Open-Meteo Forecast API

The primary data source is Open-Meteo's forecast endpoint, which blends ERA5-Land reanalysis with the ECMWF IFS, GFS, and regional NWP model outputs depending on lead time. SoilIQ requests hourly resolution with a 3-day historical lookback and a 14-day forecast horizon (past_days=3&forecast_days=14).

API Request Template
GET https://api.open-meteo.com/v1/forecast
  ?latitude={lat}&longitude={lon}
  &hourly=soil_temperature_0cm,
          soil_temperature_6cm,
          soil_temperature_18cm,
          soil_temperature_54cm,
          soil_moisture_0_to_1cm,
          precipitation
  &daily=soil_temperature_0_to_7cm_mean,
         precipitation_sum,
         temperature_2m_max,
         temperature_2m_min
  &timezone=auto
  &past_days=3
  &forecast_days=14

Open-Meteo is free for non-commercial use with a daily request budget. SoilIQ's caching layer (described in Section 13) ensures at most one forecast request per 15-minute window per location, keeping the app well within sustainable usage.

ERA5-Land Historical Archive

For historical baseline data — the 12-month climatological averages used on the soil health trend card — SoilIQ hits archive-api.open-meteo.com with a 3-year averaging window. This is a heavier query (hundreds of daily readings per call) and is cached for 7 days with a 0.15° spatial radius guard (~17 km). See Section 8.

ISRIC SoilGrids v2.0

Soil texture data comes from ISRIC SoilGrids v2.0, the global soil information service run by the International Soil Reference and Information Centre. SoilIQ queries the REST API for wv0010 (volumetric water content at −10 kPa, a proxy for sand fraction) and specifically the sand and clay properties at the 0–5 cm horizon:

SoilGrids REST Query
GET https://rest.isric.org/soilgrids/v2.0/properties/query
  ?lon={lon}&lat={lat}
  &property=sand&property=clay
  &depth=0-5cm
  &value=mean

The response encodes raw values as integers with a d_factor from the unit_measure field. SoilIQ divides raw values by d_factor and then by 1,000 to obtain the sand and clay mass fractions (g/g), with a defensive fallback to d_factor = 1.0 if the field is absent. Results are cached to UserDefaults with a 0.5° spatial radius guard (~55 km), since soil texture changes very slowly over landscape scales.

3. 4-Depth Soil Profile Model

Open-Meteo exposes soil temperature at four standardized depths that correspond to the ERA5-Land land surface model layers. SoilIQ maps these NWP depths to agronomically meaningful labels and display units:

NWP Layer (cm) App Label Agronomic Significance API Variable
0 cm Surface Germination zone; most sensitive to diurnal swing and rain events soil_temperature_0cm
6 cm (~2") 2 inches Standard planting depth for most small seeds (carrot, lettuce, beet) soil_temperature_6cm
18 cm (~6") 6 inches Root zone for transplants; standard agronomic measurement depth soil_temperature_18cm
54 cm (~21") 21 inches Deep root zone; slower seasonal signal, minimal diurnal variation soil_temperature_54cm
0 cm
Surface
6 cm / 2"
2 Inches
18 cm / 6"
6 Inches
54 cm / 21"
21 Inches

Bar width indicates relative diurnal temperature amplitude — surface swings the most, deep layers are nearly static.

4. Hourly Data Pipeline

SoilIQ parses the Open-Meteo hourly response into an internal HourlyReading model, retaining one record per hour for the current day. These feed the interactive hourly chart on the Today tab — the same chart that shows the scrub-to-explore temperature timeline.

Daily readings are computed as the arithmetic mean of all available hourly values for each calendar day. This is mathematically equivalent to a 24-sample average and is well-suited to the diurnally symmetric nature of soil thermal cycles. The daily mean also serves as the reference value for the thermal amplitude correction model (Section 5).

Daily Mean Aggregation
T_daily_mean(depth, day) = mean(T_hourly(depth, hour))
                            for hour ∈ {0..23} on that day

The hero temperature displayed at the top of the Today tab is the current hour's reading from todayHourly — not the daily mean — so it reflects "right now" as accurately as the model allows. If hourly data is unavailable (e.g., cache loaded before today's data arrived), it falls back to the daily mean.

5. Soil Thermal Amplitude Correction (STAC)

ERA5-Land's soil temperature model uses a reference loam soil texture — approximately 40% sand, 20% clay, 40% silt — as the thermal property baseline for its land surface scheme. This is appropriate for global average conditions but introduces systematic bias for growers whose actual soil departs significantly from loam.

SoilIQ implements a post-processing correction called the Soil Thermal Amplitude Correction (STAC), derived from the Johansen (1975) thermal conductivity model and Farouki (1981) soil thermal property data. The key insight is that only the diurnal amplitude is affected, not the daily mean temperature. Sandy soils have lower volumetric heat capacity and therefore amplify the daily swing; clay soils have higher heat capacity and dampen it.

Correction Factor Derivation

STAC Factor (Surface Layer)
raw_factor = 1.0
           + (sand_fraction − 0.40) × 0.45   // sand amplifies
           − (clay_fraction − 0.20) × 0.38   // clay dampens

factor_surf = clamp(raw_factor, 0.75, 1.25)  // bounded ±25%

The coefficients (0.45 for sand, 0.38 for clay) are derived from the ratio of thermal diffusivity between pure sand (~0.75 mm²/s) and pure clay (~0.30 mm²/s) relative to a loam reference (~0.55 mm²/s), scaled to produce unit response at the reference texture. The clamp bounds at ±25% prevent overcorrection for extreme textures where the first-order linear model breaks down.

Depth Attenuation

Thermal amplitude decays exponentially with depth due to the heat diffusion equation. The penetration depth of diurnal thermal waves in loam soil is approximately 15–20 cm. SoilIQ applies depth-specific attenuation fractions to the correction factor:

Depth Attenuation Factor Correction Applied Rationale
Surface (0 cm) 100% factor_surf Full diurnal swing at surface
2 inches (6 cm) 60% 1 + (factor_surf − 1) × 0.60 Significant but attenuated swing
6 inches (18 cm) 25% 1 + (factor_surf − 1) × 0.25 Near the diurnal damping depth
21 inches (54 cm) 0% None — factor = 1.0 Below diurnal penetration depth

Application: Mean-Preserving Amplitude Scaling

Per-Hour Corrected Temperature
T_corrected(hour) = T_daily_mean + (T_raw(hour) − T_daily_mean) × factor

// Where factor is depth-specific:
//   surface: factor_surf
//   2 inch:  1 + (factor_surf − 1) × 0.60
//   6 inch:  1 + (factor_surf − 1) × 0.25
//   21 inch: 1.0 (no correction)

This formulation is mean-preserving by construction: when summed across all 24 hours, the deviations from the daily mean cancel, leaving the daily mean unchanged. Only the shape of the diurnal curve is modified.

🔬

First-launch behavior: If the SoilGrids fetch has not yet completed when the hourly data arrives, the app uses the default loam reference (factor = 1.0, no correction). When the SoilGrids response completes, extractTodayHourly is re-run from cached disk data to apply the corrected factors — so the correction converges automatically without requiring a fresh network request.

6. Precipitation Cooling Model

Rainfall events cool soil temperature through two mechanisms: (1) the sensible heat exchange with rainwater, which is typically cooler than summer soil, and (2) increased evaporative cooling from the newly wetted surface. NWP soil models capture these effects with some lag and spatial averaging that can understate the magnitude of short, intense rainfall events at a hyperlocal scale.

SoilIQ applies a post-processing precipitation cooling adjustment to each daily reading:

Precipitation Cooling Model
cooling(depth) = depth_weight(depth)
               × min(3.0, precip_mm × 0.06 × (1 − moisture × 0.5))

// Depth weights:
//   Surface:  1.20  (amplified — direct rainwater contact)
//   2 inches: 1.00  (reference)
//   6 inches: 0.50  (attenuated — percolation delay)
//   21 inches: 0.15 (minimal — buffered by overlying soil)

// Moisture modulation:
//   High antecedent moisture → less additional cooling
//   (soil already near field capacity, less evaporative headroom)

The 3°C cap prevents unrealistic overcooling from extreme precipitation events. The moisture modulation term (1 − moisture × 0.5) reflects the physical reality that already-saturated soil has less capacity for additional evaporative cooling than dry soil. moisture here is the soil_moisture_0_to_1cm value from the API, normalized to 0–1.

7. NWP Artifact Guard & Gradient Synthesis

Numerical Weather Prediction models can produce a characteristic artifact in multi-week soil temperature forecasts: a sequence of days where the modeled soil temperature is nearly identical across consecutive days. This occurs at the boundary between the short-range high-resolution model (typically 7–10 days) and the medium-range ensemble model (days 10–14), where initialization discontinuities can produce implausible flat segments.

SoilIQ detects this artifact with a uniformity scan:

NWP Artifact Detection
uniform_days = count of consecutive daily readings where:
    |T(day) − T(day−1)| < 0.2°C

// Trigger: more than 50% of forecast days are "uniform"
//   i.e., uniform_days / total_days > 0.5

// When triggered: per-day gradient synthesis
//   T_synthesized(day) = T_start + (T_end − T_start)
//                        × smooth_step(day / total_days)

// smooth_step(x) = x² × (3 − 2x)  // Hermite interpolation

The gradient synthesis produces a smooth, physically plausible seasonal trend curve connecting the last reliable short-range value to the climatological expected value at the forecast horizon. Hermite interpolation is used (rather than linear) to preserve acceleration behavior — soil temperature typically continues in the same direction it was moving before flattening.

8. Historical Baseline: ERA5-Land Archive

To give context to current readings — "is this warm or cold for this time of year?" — SoilIQ computes 12 monthly climatological averages from the ERA5-Land archive. This involves fetching 3 years of daily data from archive-api.open-meteo.com and computing mean soil temperatures for each calendar month.

Archive API Request
GET https://archive-api.open-meteo.com/v1/archive
  ?latitude={lat}&longitude={lon}
  &start_date={3_years_ago}
  &end_date={yesterday}
  &daily=soil_temperature_0_to_7cm_mean
  &timezone=auto

The 12 resulting monthly means are stored in historicalMonthly: [Double] (index 0 = January). These power the contextual annotation on the Today card ("Warmer than usual for April") and the sparkline baseline on the soil health trend chart.

The archive fetch is one of the more expensive operations in the app. The 7-day cache with 0.15° radius guard ensures it runs at most once per week per ~17 km area — a rate that is trivially sustainable for any deployment scale.

9. Growing Degree Days (GDD)

Growing Degree Days are the agronomic standard for tracking accumulated heat units — the thermal budget a crop needs to reach key development milestones (germination, flowering, maturity). SoilIQ computes GDD from the air temperature 2m data included in the Open-Meteo response.

GDD Computation
// Single base temperature method (most common)
GDD_day = max(0, (T_max + T_min) / 2 − T_base)

// Multi-day accumulation
GDD_accumulated = Σ GDD_day  for day ∈ {season_start .. today}

// Fahrenheit unit conversion
// NOTE: multiply accumulated GDD by 9/5 — do NOT add 32.
// Adding 32 is correct for temperature conversion but wrong
// for degree-day differences (which are temperature deltas).
GDD_F = GDD_C × (9/5)

Base temperatures vary by crop. SoilIQ's PlantAI engine stores a gddRequired value and gddBaseTemp for each of its 133 crops. Common values:

Crop Group Base Temp (°C) GDD to Maturity
Cool-season vegetables (lettuce, spinach) 4.4°C (40°F) 600–900 GDD
Cool-season grasses (fescue, bluegrass) 5°C (41°F) 200–350 GDD to establish
Tomatoes, peppers 10°C (50°F) 1,100–1,500 GDD
Corn (sweet) 10°C (50°F) 2,200–2,700 GDD
Warm-season grasses (Bermuda, Zoysia) 15°C (59°F) Tracked for spring green-up
Squash, cucumbers, beans 10°C (50°F) 800–1,200 GDD

10. Soil Health Score (0–100)

The Soil Health Score is a composite 0–100 index that appears on the Today tab and drives the health ring visualization. It synthesizes five sub-signals into a single actionable number:

Sub-Signal Weight Logic
Temperature-to-optimal alignment 40% Distance of current soil temp from the user's primary crop's optimal range
Forecast trajectory (3-day trend) 25% Is soil warming (favorable in spring) or cooling (favorable in fall)?
Historical percentile for the month 20% Current temp vs. 3-year monthly average — normalized to a 0–100 score
Soil moisture adequacy 10% soil_moisture_0_to_1cm: penalizes both extremely dry and waterlogged conditions
Precipitation stress 5% Recent heavy rain events reduce score (temporary root stress risk)

The raw composite is clamped to [0, 100] and mapped to one of five labeled states: Excellent, Good, Fair, Poor, or Critical. The label and ring fill color are driven by this score, giving growers an immediate at-a-glance read on conditions without needing to interpret raw numbers.

11. PlantAI Engine: 133-Crop Classification

The PlantAI engine evaluates readiness for each of the 133 crops in SoilIQ's database and classifies them into four planting buckets. Each crop entry contains:

Classification buckets, evaluated in priority order:

Bucket Criteria Display
Ready Now Current temp ≥ minOptimal AND ≤ maxOptimal at preferred depth Green chip · top of list
This Week Current temp ≥ minimum AND 3-day forecast projects into optimal range Yellow chip
Coming Up Current temp ≥ minimum but not yet optimal; forecast is warming Orange chip
Not Yet Current temp < minimum, or frost risk within forecast window Red chip · bottom of list

Within each bucket, crops are sorted by how centered the current temperature is within the optimal range — so crops that are most optimally positioned appear first within their section.

12. Probe Calibration System

NWP soil temperature models operate at spatial resolutions of 9–25 km and use ensemble-averaged soil properties. Real-world conditions at a specific garden, field, or lawn can differ systematically from the model — due to localized topography, mulch cover, shade, drainage, or actual soil texture. The Probe Calibration System lets users measure the difference and correct for it permanently.

The workflow: the user takes a physical probe temperature reading at a specific depth, enters it in the app, and SoilIQ computes and stores the offset:

Probe Offset Storage & Application
// At calibration time:
offset(depth) = probe_reading_C − model_reading_C

// Persisted as: soiliq.v2.probeCalibrations (UserDefaults)
//   Dictionary keyed by depth raw value (0, 6, 18, 54)

// At render time (display, widget, PlantAI, GDD):
T_display = unit.convert(T_model + offset(depth))

// NOTE: offset is in Celsius (model native)
// unit.convert() handles C→F for display only

Critically, the offset is applied at render time — not stored into the reading itself. This preserves a clean separation between model output and user correction, allows the offset to be updated or cleared without needing to re-fetch data, and ensures that all downstream computations (PlantAI classification, GDD accumulation, health score) use the calibrated value consistently.

Offsets are stored per-depth independently. A user may calibrate only the 6-inch depth if that is the only layer where they have probe access, leaving other depths at zero offset.

13. Caching Architecture

SoilIQ uses a multi-layer, location-aware caching strategy designed to minimize API calls while keeping data fresh for the grower's actual current location.

Freshness Guard

Fetch Decision Logic
fetch_needed = true

// Skip fetch if:
if let lastFetch = lastFetchDate,
   Date().timeIntervalSince(lastFetch) < 15 * 60,  // 15-min TTL
   abs(currentLat − lastFetchLat) < 0.10,           // ~11 km radius
   abs(currentLon − lastFetchLon) < 0.10 {
    fetch_needed = false
}

The 15-minute TTL prevents API exhaustion during rapid repeated launches or background refreshes. The 0.10° spatial guard (~11 km at mid-latitudes) ensures that a short drive doesn't produce unnecessary re-fetches but a meaningful location change does.

Cache Layers by Data Type

Data Type Storage TTL Spatial Radius
Forecast (hourly + daily) JSON file in app's Documents directory 15 min (freshness guard) 0.10° (~11 km)
Historical archive (12 monthly means) UserDefaults (compact) 7 days 0.15° (~17 km)
Soil texture (sand/clay fractions) UserDefaults (two doubles) Permanent (slow-changing) 0.5° (~55 km)

Rate Limit Handling

Open-Meteo's free tier enforces a daily request limit that resets at midnight UTC (7 PM EDT / 8 PM EST). If the API returns HTTP 429 with a "limit exceeded" response body, SoilIQ detects this, serves the most recent cache silently (no error shown to the user if valid cache exists), and shows a friendly limit-exceeded message only if no cached data is available. A URLError cancellation guard prevents spurious error messages when a location change cancels an in-flight request.

14. Widget & Live Activity Pipeline

SoilIQ's home screen widgets and iOS Live Activity share data with the main app via an App Group (group.com.soiliq.app), a sandboxed shared UserDefaults container that both the app target and widget extension can read and write.

Widget Data Keys

App Group Write (called after every successful fetch)
wTemp         = heroTemp (in user's current unit, String)
wUnit         = "°F" or "°C"
wCondition    = soilConditionTitle (e.g., "Ideal Conditions")
wTrend        = "+2° last 24h" (trend annotation)
wLocationName = resolved place name from CoreLocation
wDepthLabel   = selectedDepth.shortLabel (e.g., "6\"")
wLat          = current latitude (Double)
wLon          = current longitude (Double)
wForecast5    = JSON-encoded [Double] — next 5 daily temps

// WidgetCenter.shared.reloadAllTimelines()
//   called immediately after write to force refresh

The widget extension reads these keys from the shared container in its TimelineEntry provider. Because widget refresh is triggered by reloadAllTimelines() immediately after the main app writes new data, widgets update in near-real-time whenever the app fetches fresh data — without burning a separate API request from the widget process.

Live Activity

The Live Activity uses ActivityKit with SoilIQActivityAttributes — a struct defining the static and dynamic content fields. The same attributes struct is defined identically in both the app target (for starting and updating) and the widget extension target (for rendering). SoilIQ starts, updates, and ends the Live Activity from the main data store using the @available(iOS 16.2, *) guarded API, with end(content:dismissalPolicy:) per the iOS 16.2 requirement.

The Live Activity is toggled from the Settings view, not the Today tab, to keep the primary grower interface focused on data rather than system state controls.

Experience the intelligence firsthand.

SoilIQ is free on iOS — four depths, 14-day forecast, 133 crops, live widgets, and Apple Watch. See how the algorithms above translate into planting decisions for your garden.

Download Free — iOS