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:
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.
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.
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.
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.
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.
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.
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).
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:
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 |
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).
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
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
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:
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:
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.
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.
// 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:
- Minimum soil temperature — below this, the seed will not germinate
- Optimal soil temperature range — [minOptimal, maxOptimal] for fastest emergence
- GDD required to reach maturity or key development milestone
- GDD base temperature specific to that crop species
- Preferred depth — which of the 4 model layers is most agronomically relevant
- Season flags — cool-season, warm-season, perennial, etc.
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:
// 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_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
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.