Enterprise Urban Heat Intelligence & Autonomous Advisory Platform
Built for the FortyGuard Hackathon — turning hyperlocal temperature telemetry into decisions cities can act on.
Cities do not get hot uniformly. A single street can run 8–10 °C hotter than a park four blocks away, and that difference is where heat illness, energy spikes, and infrastructure stress actually concentrate. City-scale weather feeds average this signal away.
Meridian ingests hyperlocal 20 m² resolution temperature data from the FortyGuard API, stores it as an append-only time series, derives a risk classification per zone, streams changes to an operations dashboard in real time, and puts a Groq-hosted LLM agent on top so an analyst can ask questions in plain language and get a government-ready heat advisory back.
| Problem | Urban heat risk is hyperlocal, but decision-makers see city-wide averages. |
| Input | FortyGuard hyperlocal telemetry — 20 m², 2 m above ground level. |
| Output | Live risk dashboard, correlation analysis, and AI-authored PDF/Excel advisories. |
| Scale tested | 47 monitored zones streaming concurrently across 4 continents. |
Real-time KPI tiles, sortable live-readings table, and a global Leaflet heatmap. Updates arrive over SignalR and patch the client cache directly — no polling.
Token-streamed analysis over the live dataset, powered by Groq via Microsoft Semantic Kernel.
Pearson correlation between monitored zones, per-location temperature trends, and LLM-generated insight.
Meridian is a two-tier system: a .NET 10 Clean Architecture API and a Next.js 15 App Router frontend, joined by REST for queries and SignalR for push.
flowchart LR
subgraph EXT["① External Data Sources"]
direction TB
FG["FortyGuard API<br/>hyperlocal telemetry"]
GROQ["Groq API<br/>OpenAI-compatible LLM"]
end
subgraph BE["② Backend — .NET 10 Clean Architecture"]
direction TB
INFRA["Meridian.Infrastructure<br/>EF Core · repositories · HTTP clients"]
APP["Meridian.Application<br/>use-case services · AutoMapper · worker"]
API["Meridian.API<br/>controllers · SignalR hub · exports"]
CORE["Meridian.Core<br/>entities · interfaces · Result"]
end
DB[("Neon PostgreSQL<br/>Locations · HeatReadings · Reports")]
subgraph FE["③ Frontend — Next.js 15"]
direction TB
SR["useSignalR<br/>push channel"]
RQ["React Query<br/>cache of record"]
ZU["Zustand<br/>client-only UI state"]
UI["Dashboard · Agent · Analysis<br/>Reports · Locations"]
end
FG -->|"Polly retry"| INFRA
GROQ -->|"Semantic Kernel"| INFRA
INFRA --- CORE
INFRA --> APP --> API
INFRA <-->|"EF Core 10"| DB
API -->|"REST"| RQ
API -->|"WebSocket"| SR
SR -->|"setQueryData"| RQ
RQ --> UI
ZU --> UI
classDef ext fill:#FDEEE3,stroke:#EA580C,color:#7C2D12
classDef be fill:#E7F1FF,stroke:#2563EB,color:#1E3A8A
classDef fe fill:#ECFDF5,stroke:#059669,color:#065F46
classDef db fill:#FDF3DC,stroke:#B45309,color:#78350F
class FG,GROQ ext
class API,APP,INFRA,CORE be
class RQ,SR,UI,ZU fe
class DB db
The dependency arrow points inward only — Core knows nothing about the outside world, which keeps domain logic testable and swappable.
flowchart LR
API["Meridian.API<br/>controllers, hub"] --> INFRA["Meridian.Infrastructure<br/>EF Core, HTTP"] --> APP["Meridian.Application<br/>use cases"] --> CORE["Meridian.Core<br/>domain — zero dependencies"]
classDef l fill:#E7F1FF,stroke:#2563EB,color:#1E3A8A
classDef c fill:#FDEEE3,stroke:#EA580C,color:#7C2D12
class API,INFRA,APP l
class CORE c
Each layer exposes its own DependencyInjection.cs (AddApplication() / AddInfrastructure()) which Program.cs composes — so wiring stays local to the layer that owns it.
Two independent background services write HeatReading rows. Knowing which one you are looking at matters when reasoning about data freshness.
sequenceDiagram
autonumber
participant FG as FortyGuard API
participant W as HeatIngestionWorker
participant SIM as LiveHeatSimulatorService
participant DB as PostgreSQL
participant HUB as SignalR HeatHub
participant FE as React Query cache
participant UI as Dashboard
rect rgb(253, 238, 227)
note over W: Real data path — every 15 minutes
W->>FG: fetch temperature for active locations
FG-->>W: temp · humidity · heat index
W->>DB: INSERT HeatReading (RiskLevel derived)
W->>HUB: notify
end
rect rgb(231, 241, 255)
note over SIM: Demo liveliness path — every 2.5 s
SIM->>DB: mutate last reading ±1.5 °C
SIM->>HUB: broadcast ReceiveHeatReading
end
HUB-->>FE: ReceiveHeatReading (WebSocket)
FE->>FE: setQueryData(['dashboard']) upsert by locationId
FE->>FE: recompute extremeRiskCount · highRiskCount · globalAverageTemp
FE-->>UI: re-render (zero refetch)
note over FE: onreconnected → full invalidate as catch-up
Important
LiveHeatSimulatorService (2.5 s tick by default) exists to keep the demo visually alive — it is not real telemetry. Disable it with Simulator__Enabled=false before drawing conclusions about data volume or freshness. HeatIngestionWorker (15 min poll) is the real FortyGuard path. DataRetentionService trims raw readings past 7 days so neither writer can grow the table without bound.
React Query is the cache of record. When a reading arrives, useSignalR merges it into ['dashboard'] with setQueryData and recomputes the aggregates client-side. That gives zero-latency updates without a network round-trip, which is why refetchOnWindowFocus is deliberately off — SignalR, not window focus, is the freshness mechanism.
RiskLevel is derived from temperature at write time via RiskLevelExtensions.FromTemperature, stored on the reading, and serialized as a string over JSON (both MVC and the SignalR protocol) so the frontend union type stays readable.
flowchart LR
T["Temperature °C"]
T -->|"< 30"| LOW["LOW"]
T -->|"30 – 37.9"| MOD["MODERATE"]
T -->|"38 – 43.9"| HIGH["HIGH"]
T -->|"≥ 44"| EXT["EXTREME"]
classDef low fill:#DCFCE7,stroke:#16A34A,color:#14532D
classDef mod fill:#FEF3C7,stroke:#D97706,color:#78350F
classDef high fill:#FEE2E2,stroke:#DC2626,color:#7F1D1D
classDef ext fill:#FECDD3,stroke:#991B1B,color:#7F1D1D
class LOW low
class MOD mod
class HIGH high
class EXT ext
| Level | Range (°C) | Marker |
|---|---|---|
Low |
< 30 |
#22c55e |
Moderate |
30 – 37.9 |
#f59e0b |
High |
38 – 43.9 |
#ef4444 |
Extreme |
>= 44 |
#7c2d12 |
Keep the frontend RiskLevel string union in sync with Meridian.Core.Common.RiskLevel.
| Concern | Choice | Notes |
|---|---|---|
| Runtime | .NET 10 Web API | Clean Architecture, 4 projects |
| Data | EF Core 10 + Npgsql → Neon PostgreSQL | migrations auto-apply on startup |
| AI | Microsoft Semantic Kernel 1.80 → Groq | AddOpenAIChatCompletion pointed at Groq's OpenAI-compatible endpoint |
| Resilience | Polly | FortyGuard: 3× 500 ms · Groq: exponential backoff, handles 429, 2 min timeout |
| Realtime | SignalR | hub at /hubs/heat |
| Mapping | AutoMapper 16 | entity → response DTOs |
| Validation | FluentValidation 11 | request validation |
| Logging | Serilog | structured console sink |
| Export | QuestPDF + ScottPlot · ClosedXML | PDF with charts · Excel |
| Docs | Swashbuckle | Swagger UI at /swagger in Development |
| Concern | Choice | Notes |
|---|---|---|
| Framework | Next.js 15 App Router + React 19 | |
| Server state | TanStack React Query 5 | cache of record; SignalR patches it directly |
| Client state | Zustand 5 | selected location, agent transcript — UI only |
| Realtime | @microsoft/signalr 10 | useSignalR hook |
| Mapping | Leaflet + react-leaflet + leaflet.heat | Carto basemap tiles |
| Charts | Recharts 3 | loaded client-side only |
| HTTP | Axios | shared instance, error-normalizing interceptor |
| Styling | Tailwind CSS v4 | CSS custom-property design tokens |
| Type safety | TypeScript 5 strict |
All routes are prefixed api/[controller]. Global fixed-window rate limit: 60 req/min.
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/api/heat |
All heat readings |
GET |
/api/heat/dashboard |
Aggregated dashboard snapshot (KPIs + latest per zone) |
GET |
/api/heat/location/{locationId} |
Readings for one zone |
GET |
/api/heat/history |
Historical series |
POST |
/api/heat/ingest |
Trigger a FortyGuard ingestion pass |
GET |
/api/location |
List monitored zones |
POST |
/api/location |
Create a zone |
POST |
/api/location/bulk |
Bulk CSV-style zone import |
DELETE |
/api/location/{id} |
Delete a zone |
DELETE |
/api/location/all |
Clear all zones |
GET |
/api/analysis/correlations |
Pearson correlation across zones |
GET |
/api/analysis/trend/{locationId} |
Temperature trend for a zone |
GET |
/api/analysis/location/{locationId} |
Per-zone analysis |
POST |
/api/agent/query |
One-shot LLM analysis |
POST |
/api/agent/stream |
Token-streamed analysis (IAsyncEnumerable) |
POST |
/api/report/generate |
Generate an AI advisory report |
GET |
/api/report |
List reports |
DELETE |
/api/report/{id} |
Delete a report |
GET |
/api/export/pdf |
PDF export (QuestPDF + ScottPlot charts) |
GET |
/api/export/excel |
Excel export (ClosedXML) |
Realtime: WS /hubs/heat → event ReceiveHeatReading
- .NET 10 SDK
- Node.js 18+
- A PostgreSQL connection string (Neon free tier works)
- API keys for FortyGuard and Groq
Create backend/Meridian.API/appsettings.Development.json (gitignored):
{
"ConnectionStrings": {
"DefaultConnection": "Host=...;Database=...;Username=...;Password=...;SSL Mode=Require"
},
"FortyGuard": { "ApiKey": "your-fortyguard-key" },
"Groq": { "ApiKey": "your-groq-key" }
}cd backend/Meridian.API
dotnet runAPI starts on http://localhost:5250 (https://localhost:7142). EF Core migrations apply automatically on startup — no separate migration step for local dev. Swagger UI: http://localhost:5250/swagger
cd frontend
cp .env.example .env.local # NEXT_PUBLIC_API_URL=http://localhost:5250
npm install
npm run devApp starts on http://localhost:3000.
Note
Start the backend first. The frontend opens a SignalR connection on mount; if the API is not up you will see FailedToNegotiateWithServerError in the console until it is.
# Backend
dotnet build backend/Meridian.slnx
cd backend/Meridian.API && dotnet ef migrations add <Name> -p ../Meridian.Infrastructure -s .
cd backend/Meridian.API && dotnet ef database update -p ../Meridian.Infrastructure -s .
# Frontend
cd frontend && npm run build
cd frontend && npx tsc --noEmit # type-checkMeridian/
├── backend/
│ ├── Meridian.Core/ # Entities, interfaces, Result<T> — no dependencies
│ │ ├── Entities/ # Location, HeatReading, Report
│ │ └── Common/ # RiskLevel, Result pattern
│ ├── Meridian.Application/ # Use-case services, AutoMapper profiles
│ │ └── Services/ # HeatIngestionWorker (15-min FortyGuard poll)
│ ├── Meridian.Infrastructure/ # EF Core, repositories, external clients
│ │ ├── Repositories/ # Repository<T> + heat/location/report repos
│ │ ├── External/ # FortyGuardClient, GroqAgentService
│ │ └── Migrations/ # incl. AddHeatReadingIndex
│ └── Meridian.API/ # Controllers, SignalR hub, exports
│ ├── Controllers/ # Heat, Location, Analysis, Agent, Report, Export
│ ├── Hubs/ # HeatHub → /hubs/heat
│ ├── Services/ # LiveHeatSimulatorService (2.5-s demo tick)
│ └── Exports/ # QuestPDF + ScottPlot, ClosedXML
└── frontend/
├── public/logo.png
└── src/
├── app/ # / · /agent · /analysis · /locations · /reports
│ └── globals.css # design tokens (@theme + :root)
├── components/
│ ├── ui/ # Button, Card, Badge, RiskBadge, CommandPalette
│ ├── shared/ # Sidebar, GlobalAlerts
│ └── features/ # dashboard widgets, TimeLapseSlider
├── hooks/useSignalR.ts # push channel → React Query cache
└── lib/ # axios client, Zustand store
Speed is treated as a standing constraint, not a late optimization pass.
- Database — indexed, filtered, paged queries instead of full-table loads;
AsNoTracking()on read paths; an explicit EF migration (AddHeatReadingIndex) rather than relying on PK-only indexing; N+1 patterns onHeatReading → Locationactively avoided. - API — hot paths (dashboard, ingestion, broadcast) are async end-to-end and allocation-light; a single reused
HttpClientper integration with Polly policies rather than per-request clients. - Frontend — React Query stays the only fetch surface; heavy client-only libraries (Leaflet, Recharts) sit behind
dynamic(..., { ssr: false }); theCommandPalettedashboard query is gated onenabled: openso it cannot fire on every route; unused dependencies (Mapbox GL) were audited out.
An unlayered reset in globals.css —
* { box-sizing: border-box; margin: 0; padding: 0; }— was silently zeroing every Tailwind spacing utility app-wide. Tailwind v4 emits utilities inside @layer utilities, and unlayered CSS always outranks layered CSS regardless of specificity, so p-3, px-5, and py-2 all computed to 0px. The fix was to move base styles into @layer base and drop the duplicate reset, since Tailwind preflight already handles it in the correct layer.
Light, flat, and deliberately low-chrome — a Vercel-adjacent operations register.
| Token group | Values |
|---|---|
| Surfaces | --bg-base #F3F4F8 · --bg-subtle · --bg-elevated #FFFFFF |
| Accent | --accent #EA580C (heat orange), single accent, no gradients |
| Borders | hairline --border-subtle / --border-default |
| Risk | --risk-low --risk-moderate --risk-high --risk-extreme |
| KPI tints | 4 flat pastel bg/fg pairs, each bound to a real metric |
| Shape | rounded-full pills · rounded-xl nav · rounded-2xl/3xl cards |
All tokens live in src/app/globals.css as CSS custom properties, re-exposed to Tailwind v4 through @theme so opacity modifiers (bg-accent/10) work. Change a token there rather than hardcoding a color in a component. Semantic risk colors are intentionally independent of the neutral/accent palette.
Built for the FortyGuard Hackathon.


