Centraline Ambientali API
Technical reference for the environmental monitoring system. Covers all 22 endpoints, database schemas, calculation formulas (replicated from Excel), and equivalent Django + Angular code for full migration.
Architecture Overview
The system monitors air quality across the Bagnoli area (Naples) using ISPRA and Invitalia stations, combined with meteorological and transport data. The current stack is PHP (single-file API) backed by two PostgreSQL schemas and a local SQLite auth database.
Current Stack
- Backend: PHP 8.x single-file API (
api/index.php+api/indices.php+api/config.php) - Database: PostgreSQL (2 schemas on same DB) + SQLite (auth)
- Auth: JWT (HS256, 24h expiry)
- Frontend: Vanilla JS SPA
Target Stack (Migration)
- Backend: Django 5.x + Django REST Framework
- Frontend: Angular 19 with standalone components
- Auth: SimpleJWT (djangorestframework-simplejwt)
Base URL
// Current PHP
https://<host>/api/?action=<endpoint>¶m=value
// Django equivalent
https://<host>/api/v1/<endpoint>/
Authentication
The API uses JWT Bearer tokens. Public endpoints require no token. Private endpoints require an Authorization: Bearer <token> header. Tokens expire after 24 hours.
sub (user ID), username, role, and exp (expiration timestamp).
Auth Flow
// 1. Obtain token
POST /api/?action=login
Body: { "username": "admin", "password": "" }
Response: { "token": "eyJ...", "user": { "id": 1, "username": "admin", "role": "admin" } }
// 2. Use token in subsequent requests
GET /api/?action=indices&id=2&from=2026-01-01&to=2026-01-07
Header: Authorization: Bearer eyJ...
Database Schema
Connection Details
// PostgreSQL (both schemas on same DB server)
Host: hetzner-dbserver-dev.sviluppo-sw.it
Port: 5432
Database: devbagnolicrm
User: devbagnolicrm
// Schema 1: bagnoli_ambiente_dev (ISPRA, meteo, centraline)
// Schema 2: bagnoli_ambiente (Invitalia, trasporti)
// SQLite: local auth.sqlite for users table
Schema: bagnoli_ambiente_dev
Table: centralina
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | Auto-increment primary key |
| name | TEXT | Display name |
| alias | TEXT | Short code (e.g. LM03, MMB_274) |
| codice | TEXT | Official station code |
| tipo | TEXT | Station type (ISPRA, Invitalia) |
| external_id | TEXT | External reference ID |
| url | TEXT | Data source URL |
| status | TEXT | Active/inactive |
| colore_mappa | TEXT | Map marker color hex |
| is_manuale | BOOLEAN | Manual data entry station |
| raggio_mappa | INTEGER | Map radius in meters |
| created | TIMESTAMP | |
| updated | TIMESTAMP |
Table: centralina_localizzazione
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | |
| id_centralina | INTEGER FK | References centralina.id |
| lat | NUMERIC | Latitude |
| lng | NUMERIC | Longitude |
| nome_comune | TEXT | Municipality name |
| nome_provincia | TEXT | Province name |
| data_inizio | DATE | Location start date |
| stato | INTEGER | 1 = active location |
| created | TIMESTAMP | |
| updated | TIMESTAMP |
Table: opas_ispra_1793 (Station MMB_274, id_centralina=1)
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | |
| id_centralina | INTEGER | Always 1 for this table |
| data_ora | TIMESTAMP | Hourly reading timestamp |
| so2 | NUMERIC | Sulfur dioxide (ug/m3) |
| o3 | NUMERIC | Ozone (ug/m3) |
| no2 | NUMERIC | Nitrogen dioxide (ug/m3) |
| co | NUMERIC | Carbon monoxide (mg/m3) |
| pm2_5_fidas | NUMERIC | PM2.5 (ug/m3) |
| pm10_fidas | NUMERIC | PM10 (ug/m3) |
| benzene | NUMERIC | Benzene (ug/m3). See note on old/new format |
| stato | INTEGER |
Table: opas_ispra_1798 (Station LM03, id_centralina=2)
Same structure as opas_ispra_1793 but for station LM03 (id=2). Column so2 is always NULL for this station.
Table: meteostatnet_dati
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | |
| id_centralina | INTEGER | |
| time | TIMESTAMP | Observation time |
| temp | NUMERIC | Temperature (C) |
| dwpt | NUMERIC | Dew point (C) |
| rhum | NUMERIC | Relative humidity (%) |
| prcp | NUMERIC | Precipitation (mm) |
| snow | NUMERIC | Snow (mm) |
| wdir | NUMERIC | Wind direction (degrees) |
| wspd | NUMERIC | Wind speed (km/h) |
| wpgt | NUMERIC | Wind gust (km/h) |
| pres | NUMERIC | Atmospheric pressure (hPa) |
| tsun | NUMERIC | Sunshine duration (hours) |
| coco | INTEGER | Weather condition code |
Schema: bagnoli_ambiente
Table: centraline_invitalia
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | |
| id_centralina | INTEGER | 7=ST.1, 8=ST.3, 9=ST.4, 10=ST.5, 11=ST.2 |
| stazione | TEXT | Station name string |
| data_ora | TIMESTAMP | Hourly observation time |
| pm10_orario | NUMERIC | Hourly PM10 (ug/m3) |
| pm10_giornaliero | NUMERIC | Daily PM10 (ug/m3) |
Table: trasporti
| Column | Type | Notes |
|---|---|---|
| data | DATE | Date |
| totale_generale | NUMERIC | Total vehicles count |
| additional columns | NUMERIC | Per-category breakdowns |
Table: invitalia_dati
| Column | Type | Notes |
|---|---|---|
| id | SERIAL PK | |
| id_centralina | INTEGER | |
| data_campionamento | DATE | Sampling date |
| pm10 | NUMERIC | Manual PM10 measurement |
| codice_filtro_pm10 | TEXT | Filter code for PM10 |
| pm2_5 | NUMERIC | Manual PM2.5 measurement |
| codice_filtro_pm2_5 | TEXT | Filter code for PM2.5 |
| note | TEXT | |
| stato | INTEGER |
SQLite: auth.sqlite
Table: users
| Column | Type | Notes |
|---|---|---|
| id | INTEGER PK | Auto-increment |
| username | TEXT UNIQUE | |
| password | TEXT | bcrypt hash |
| role | TEXT | Default: "user" |
| created_at | DATETIME |
Station ID Mapping
| ID | Code | Name | Type | Table | Schema |
|---|---|---|---|---|---|
| 1 | MMB_274 | Museo/Pontile Nord | ISPRA | opas_ispra_1793 | bagnoli_ambiente_dev |
| 2 | LM03 | Citta della Scienza | ISPRA | opas_ispra_1798 | bagnoli_ambiente_dev |
| 7 | AR_CO_ST_1 | Stazione 1 | Invitalia | centraline_invitalia | bagnoli_ambiente |
| 8 | AR_CO_ST_3 | Stazione 3 | Invitalia | centraline_invitalia | bagnoli_ambiente |
| 9 | AR_CO_ST_4 | Stazione 4 | Invitalia | centraline_invitalia | bagnoli_ambiente |
| 10 | AR_CO_ST_5 | Stazione 5 | Invitalia | centraline_invitalia | bagnoli_ambiente |
| 11 | AR_CO_ST_2 | Stazione 2 | Invitalia | centraline_invitalia | bagnoli_ambiente |
Calculation Formulas
All indices are replicated exactly from the Excel file convertire.xlsm. Each formula documents the original Excel cell reference.
ICN - Indice di Conformita Normativa
Ratio of measured value to regulatory limit (D.Lgs. 155/2010). A value greater than 1.0 means the limit is exceeded.
Excel Reference: Sheets "Citta della scienza" / "Pontile nord", Columns T-Y
T = PM10 / 50 // D.Lgs. limit hourly PM10
U = PM2.5 / 25 // D.Lgs. limit annual PM2.5
V = NO2 / 200 // D.Lgs. limit hourly NO2
W = O3 / 120 // D.Lgs. limit 8h-max O3
X = Benzene / 5 // D.Lgs. limit annual Benzene
Y = AVERAGE(T, U, V, W, X) // ALWAYS divides by 5, null treated as 0
IFERROR(value/limit, "") where empty cells become 0 in the AVERAGE.
(value/1000)/5. Values ≤ 5 are new format and just need value/5.
class IndicesCalculator:
LIMITS = {'PM10': 50, 'PM2.5': 25, 'NO2': 200, 'O3': 120, 'Benzene': 5}
def compute_icn(self, row: dict) -> dict:
icn = {}
for key, limit in self.LIMITS.items():
field = key.lower().replace('.', '') # pm25
val = row.get(field)
if key == 'Benzene' and val is not None and val > 5:
val = val / 1000 # old format fix
icn[f'icn_{field}'] = round(val / limit, 4) if val is not None else 0
icn['icn_sintetico'] = round(
sum(v for k, v in icn.items()) / 5, 4
)
return icn
IPP - Indice di Pressione Particellare
Weighted particulate matter pressure index combining PM10 and PM2.5.
Excel Reference: Column Z
Z = (PM10 / 40) * 0.4 + (PM2.5 / 25) * 0.6
// Returns 0 when both PM10 and PM2.5 are null
// 40 = annual regulatory limit for PM10
// 25 = annual regulatory limit for PM2.5
// Weights: PM10 = 40%, PM2.5 = 60%
def compute_ipp(self, pm10, pm25) -> float:
if pm10 is None or pm25 is None:
return 0
return round((pm10 / 40) * 0.4 + (pm25 / 25) * 0.6, 4)
IRSR - Indice di Rischio Sanitario Relativo
Ratio against WHO 2021 Air Quality Guidelines. Uses DYNAMIC divisor (unlike ICN).
Excel Reference: Columns AA-AF (WHO thresholds)
AA = PM10 / 45 // WHO PM10 daily guideline
AB = PM2.5 / 15 // WHO PM2.5 daily guideline
AC = NO2 / 10 // WHO NO2 annual guideline
AD = O3 / 100 // WHO O3 8h guideline
AE = Benzene / 5 // WHO Benzene guideline
AF = AVERAGE(AA:AE)
// DIFFERENT from ICN: AVERAGE ignores empty cells (dynamic divisor)
// e.g. if only PM10 and PM2.5 have data: divisor = 2, not 5
// EXCEPTION: Benzene null is included as 0 (divisor still counts it)
WHO = {'PM10': 45, 'PM2.5': 15, 'NO2': 10, 'O3': 100, 'Benzene': 5}
def compute_irsr(self, row: dict) -> dict:
irsr = {}
values = []
for key, limit in self.WHO.items():
field = key.lower().replace('.', '')
val = row.get(field)
if val is not None:
r = round(val / limit, 4)
irsr[f'irsr_{field}'] = r
values.append(r)
elif key == 'Benzene':
irsr['irsr_benzene'] = 0
values.append(0) # Benzene always counted
else:
irsr[f'irsr_{field}'] = None
irsr['irsr_sintetico'] = (
round(sum(values) / len(values), 4) if values else 0
)
return irsr
ISIT - Indice Sintetico Inquinamento per Tipologia
Groups pollutants by family (particulate, reactive gases, VOC) with weighted aggregation.
Excel Reference: Columns AG-AP
// Step 1: Normalize each pollutant to percentage
norm_NO2 = (NO2 / 200) * 100
norm_O3 = (O3 / 120) * 100
norm_PM25 = (PM2.5 / 25) * 100
norm_PM10 = (PM10 / 50) * 100
norm_Benz = (Benzene / 5) * 100
// Step 2: Family averages (null treated as 0, AVERAGE always on 2 slots)
Particolato = AVERAGE(norm_PM25, norm_PM10) // always /2
Gas_Reattivi = AVERAGE(norm_NO2, norm_O3) // always /2
VOC = norm_Benz // single value
// Family = null ONLY if NO component has data
// Step 3: Weighted composite
ISIT = (Particolato * 0.4 + Gas_Reattivi * 0.4 + VOC * 0.2) / 100
// Only families with data are included in the sum
// For Invitalia stations (PM-only): ISIT = (Particolato * 0.4) / 100
Meteo Indices (10 indices)
Excel Reference: Sheet "METEO", Columns O-X
O = Temp - DewPoint // Dew Point Deficit
P = (Temp - DewPoint) / Temp // Air Saturation Index
Q = 0.5 * Wind^2 // Wind Energy
R = Gust - Wind // Turbulence Index
S = Humidity / Temp // Hygrometric Gradient
T = Humidity / (Wind + 0.1) // Stagnation Index (0.1 avoids div/0)
U = Pressure[t] - Pressure[t-1] // Pressure Delta (needs previous row)
V = (Temp * Wind) / Humidity // Evaporation Index
W = Precipitation / Humidity // Precipitation Index
X = Wind * SunshineDuration // Ventilation Index
IPRP - Indice Previsionale Rischio PM
Score-based system that predicts PM peak risk from weather conditions.
Excel Reference: Sheet "METEO", Columns AA-AI
// Step 1: Individual scores (0-3)
score_vento = IF(Wind<1, 3, IF(Wind<2, 2, IF(Wind<3, 1, 0)))
score_umidita = IF(Humid>=90, 3, IF(Humid>=80, 2, IF(Humid>=70, 1, 0)))
score_dewpoint = IF(DPD<=2, 3, IF(DPD<=4, 2, IF(DPD<=6, 1, 0)))
score_pioggia = IF(Rain==0, 3, IF(Rain<1, 2, IF(Rain<3, 1, 0)))
score_pressione = IF(Press>=1020, 2, IF(Press>=1015, 1, 0))
score_sole = IF(Sun<1, 2, IF(Sun<4, 1, 0))
// Step 2: Weighted composite
IPRP = score_vento * 0.30
+ score_umidita * 0.20
+ score_dewpoint * 0.20
+ score_pioggia * 0.15
+ score_pressione * 0.10
+ score_sole * 0.05
// Step 3: Classification
IPRP < 0.8 => "Basso" prob_picco = 20% "Nessuna criticita"
IPRP < 1.5 => "Moderato" prob_picco = 45% "Attenzione"
IPRP < 2.2 => "Alto" prob_picco = 70% "Preallerta"
IPRP >= 2.2 => "Molto alto" prob_picco = 90% "Allerta"
Forecast Model (Linear Regression)
Hourly pollutant forecast using autoregressive model with weather and traffic features.
Excel Reference: Sheet "Previsione Inquinanti", Columns P-R
// Traffic index by hour
Traffic = IF(hour in [7-9, 17-19], 8, IF(hour in [10-16], 5, 2))
// PM10 forecast
PM10[t] = 0.55*PM10[t-1] + 0.15*PM10[t-2] + 0.10*Traffic
- 1.2*Wind[t-1] - 0.8*Rain[t-1] + 0.06*Humidity[t-1]
+ 8 // intercept
// PM2.5 forecast
PM25[t] = 0.60*PM25[t-1] + 0.20*PM25[t-2] + 0.08*Traffic
- 0.9*Wind[t-1] - 0.5*Rain[t-1] + 0.05*Humidity[t-1]
+ 0.03*Temp[t-1] + 5 // intercept
// NO2 forecast
NO2[t] = 0.50*NO2[t-1] + 0.20*NO2[t-2] + 0.18*Traffic
- 1.0*Wind[t-1] + 0.03*Humidity[t-1] - 0.04*Temp[t-1]
+ 6 // intercept
// All predictions clamped to max(0, value)
Weekly Report Aggregation
Excel Reference: Sheets REPORT-CS / REPORT-PN / REPORT-ST.*
// Daily average: AVERAGEIFS on hourly data for each day
daily_pm10[day] = AVG(hourly PM10 where date = day)
// Weekly average (Col D)
avg_pm10_week = AVG(daily_pm10 for days in week)
// Exceedances (Col L): days with daily average > 50
days_over_50 = COUNT(daily_pm10 > 50)
// Cumulative exceedances (Col M): running sum from Jan 1
cumulative = SUM(days_over_50 for all weeks up to this one)
// ITIA% (Col N): fraction of allowed exceedances remaining
ITIA = 1 - (cumulative / 35) // 35 = max allowed per D.Lgs. 155/2010
// IRP (Col O): Excel-compatible mode (replica formula committente)
// Excel cell: =(M*365)/(35*GIORNO(C-DATA(ANNO(C);1;0)))
// GIORNO() interpreta day_of_year come serial date 1900 → day-of-month
excel_day = excelDayOfYearBug(day_of_year)
IRP = (cumulative * 365) / (35 * excel_day)
// W13/2026 LM03: cum=17, doy=88, excel_day=28 → IRP = 6.33
IDS - Inter-Station Divergence
Excel Reference: Sheet "Divergenza tra Stazioni"
// Coefficient of Variation (requires at least 2 stations)
IDS = STDEV(station_values) / AVERAGE(station_values)
// Classification
IDS < 0.2 => "contenuta" "Sorveglianza ordinaria"
IDS < 0.5 => "moderata" "Verifica meteorologica e territoriale"
IDS >= 0.5 => "marcata" "Approfondimento istruttorio consigliato"
// fonte_max = station with highest value
KPI Composite (Dashboard)
Excel Reference: Sheet "Dashboard", Cell Y27
// Weighted sum of all indices
KPI = SUMPRODUCT(values, weights) / SUM(weights)
// Weights:
IRP = 0.10
ICN = 0.10
IPP = 0.20
IRSR = 0.15
ISIT = 0.15
// Only indices with non-null values are included
// SUM(weights) adjusts to available indices
Pearson Correlation
Excel Reference: Sheet "Cfr Centraline", Cell C2
// Standard Pearson r between two station's daily PM10 series
r = CORREL(station_A_values, station_B_values)
// Requires at least 3 common days with data
// Computed for all pairs of stations
// Output: NxN correlation matrix for PM10 and PM2.5 separately
Public Endpoints
Returns all stations with their current location data (lat/lng, municipality).
Parameters
None
Response
Response 200{
"data": [
{
"id": 1,
"name": "Museo / Pontile Nord",
"alias": "MMB_274",
"codice": "IT1507A",
"tipo": "ISPRA",
"status": "active",
"colore_mappa": "#2196F3",
"lat": 40.8128,
"lng": 14.1694,
"nome_comune": "Napoli",
"nome_provincia": "NA"
},
{
"id": 2,
"name": "Citta della Scienza",
"alias": "LM03",
"tipo": "ISPRA",
"lat": 40.8096,
"lng": 14.1632
}
]
}
SQL Query
SELECT c.*, cl.lat, cl.lng, cl.nome_comune, cl.nome_provincia
FROM centralina c
LEFT JOIN centralina_localizzazione cl
ON cl.id_centralina = c.id AND cl.stato = 1
ORDER BY c.id
Django View
class CentralinaViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Centralina.objects.select_related('active_location').all()
serializer_class = CentralinaSerializer
Parameters
| Param | Type | Description |
|---|---|---|
| id required | integer | Station ID (1, 2, 7-11) |
Response
Response 200{
"data": {
"id": 2,
"name": "Citta della Scienza",
"alias": "LM03",
"lat": 40.8096,
"lng": 14.1632,
"nome_comune": "Napoli",
"nome_provincia": "NA"
}
}
Response 404
{ "error": "Not found" }
Raw hourly meteorological data from meteostatnet_dati table.
Parameters
| Param | Type | Description |
|---|---|---|
| from optional | date | Start date (default: 7 days ago) |
| to optional | date | End date (default: today) |
| limit optional | integer | Max rows (default: 500, max: 5000) |
Response
Response 200{
"data": [
{
"id": 15234,
"id_centralina": 1,
"time": "2026-04-07 14:00:00",
"temp": 18.5,
"dwpt": 12.3,
"rhum": 67,
"prcp": 0,
"snow": null,
"wdir": 220,
"wspd": 14.4,
"wpgt": 28.1,
"pres": 1013.2,
"tsun": 5.2,
"coco": 3
}
],
"filters": { "from": "2026-04-01", "to": "2026-04-07", "limit": 500 }
}
Hourly pollutant readings from ISPRA stations. When id is omitted, data from both stations is merged and sorted by timestamp.
Parameters
| Param | Type | Description |
|---|---|---|
| id optional | integer | 1 = MMB_274 (opas_ispra_1793), 2 = LM03 (opas_ispra_1798). Omit for both. |
| from optional | date | Start date (default: 7 days ago) |
| to optional | date | End date (default: today) |
| limit optional | integer | Max rows (default: 500, max: 5000) |
Response
Response 200{
"data": [
{
"id": 89012,
"id_centralina": 2,
"data_ora": "2026-04-07 13:00:00",
"so2": null,
"o3": 78.4,
"no2": 32.1,
"co": 0.4,
"pm2_5_fidas": 11.8,
"pm10_fidas": 24.6,
"benzene": 0.82
}
],
"filters": { "id": 2, "from": "2026-04-01", "to": "2026-04-07" }
}
opas_ispra_1793, id=2 queries opas_ispra_1798. Without id, both tables are queried and results merged.
Returns the most recent reading from each data source: meteo, ISPRA (per station), and Invitalia (per station via DISTINCT ON).
Parameters
None
Response
Response 200{
"meteo": {
"time": "2026-04-09 08:00:00",
"temp": 16.2,
"rhum": 72,
"wspd": 8.5,
"pres": 1015.4
},
"ispra": [
{
"id_centralina": 1,
"data_ora": "2026-04-09 07:00:00",
"pm10_fidas": 28.3,
"no2": 41.2
}
],
"invitalia": [
{
"id_centralina": 7,
"stazione": "AR_CO_ST_1",
"data_ora": "2026-04-09 06:00:00",
"pm10_orario": 22.1,
"pm10_giornaliero": 19.8
}
]
}
Parameters
| Param | Type | Description |
|---|---|---|
| days optional | integer | Lookback days (default: 30, max: 365) |
Response
Response 200{
"data": {
"meteo": {
"records": 720,
"avg_temp": 15.3,
"min_temp": 6.1,
"max_temp": 22.8,
"avg_humidity": 71.5,
"total_rain": 42.6,
"avg_wind": 11.2,
"avg_pressure": 1014.8
},
"ispra_1": {
"records": 680,
"avg_no2": 28.45,
"avg_o3": 62.31,
"avg_pm25": 12.87,
"avg_pm10": 25.14
},
"ispra_2": { /* same structure */ }
},
"filters": { "days": 30 }
}
Full-year weekly report for one station. Replicates Excel sheets REPORT-CS, REPORT-PN, REPORT-ST. Includes daily PM10/PM2.5 averages, exceedance tracking, ITIA%, IRP, all indices (ICN, IPP, IRSR, ISIT), meteo, and transport context.
Parameters
| Param | Type | Description |
|---|---|---|
| id required | integer | Station ID |
| year optional | integer | Year (default: current year) |
Response
Response 200{
"data": [
{
"week": "2026-W01",
"week_start": "2025-12-29",
"week_end": "2026-01-04",
"avg_pm10": 32.45,
"avg_pm25": 18.22,
"daily_pm10": {
"2025-12-29": 28.1,
"2025-12-30": 35.6,
"2025-12-31": 41.2,
"2026-01-01": 52.8,
"2026-01-02": 29.3,
"2026-01-03": 22.7,
"2026-01-04": 17.5
},
"days_over_50": 1,
"cumulative_exceedances": 1,
"itia_pct": 0.9714,
"irp": 2.6071,
"worst_day": "2026-01-01",
"worst_value": 52.8,
"no2_max": 58.3,
"o3_max": 95.1,
"benzene_max": 1.82,
"avg_wind": 12.4,
"avg_rain": 0.3,
"avg_transport": 4521.5,
"icn": 0.2841,
"ipp": 0.7612,
"irsr": 0.9523,
"isit": 0.3104
}
],
"centralina_id": 2,
"year": 2026,
"observed": {
"pm10": 26.83, "pm25": 14.21,
"no2": 30.44, "o3": 58.72, "benzene": 0.94
},
"limits": { "pm10": 40, "pm25": 25, "no2": 40, "benzene": 5 }
}
Daily PM10 averages for ALL stations side-by-side, plus transport data. Replicates Excel sheet REPORT-PM10_ALL.
Parameters
| Param | Type | Description |
|---|---|---|
| from optional | date | Start date (default: Jan 1 current year) |
| to optional | date | End date (default: today) |
Response
Response 200{
"data": [
{
"data": "2026-04-07",
"limite": 50,
"LM03": 23.45,
"MMB_274": 27.81,
"AR_CO_ST_1": 19.22,
"AR_CO_ST_3": 21.60,
"AR_CO_ST_4": 18.33,
"AR_CO_ST_5": 25.10,
"trasporti": 4850
}
],
"filters": { "from": "2026-01-01", "to": "2026-04-07" }
}
Complete dashboard payload for a given ISO week. Includes weekly reports for ALL 7 stations, KPI composites, meteo averages, and transport averages.
Parameters
| Param | Type | Description |
|---|---|---|
| week optional | string | ISO week format YYYY-WNN (default: current week) |
Response
Response 200{
"week": "2026-W14",
"period": { "from": "2026-03-30", "to": "2026-04-05" },
"reports": [
{
"centralina_id": 2,
"avg_pm10": 22.4,
"avg_pm25": 12.8,
"days_over_50": 0,
"cumulative_exceedances": 3,
"itia_pct": 0.9143,
"irp": 1.0952
}
],
"kpis": [
{
"centralina_id": 2,
"kpi": 0.4521,
"indicators": {
"itia": 0.9143, "irp": 1.0952,
"icn": 0.2841, "ipp": 0.5612,
"irsr": 0.7123, "isit": 0.2104
}
}
],
"meteo": { "avg_wind": 10.5, "avg_rain": 1.2 },
"transport": { "avg_total": 4230.5 }
}
Returns the D.Lgs. 155/2010 and WHO 2021 guideline limits used in all index calculations.
Response
Response 200{
"data": {
"PM10": { "orario": 50, "annuale": 40, "max_sforamenti": 35 },
"PM2.5": { "annuale": 25 },
"NO2": { "orario": 200, "annuale": 40 },
"O3": { "8h_max": 120, "info": 180, "allarme": 240 },
"CO": { "8h_max": 10 },
"Benzene": { "annuale": 5 },
"SO2": { "orario": 350, "giornaliero": 125, "allarme": 500 }
},
"who": {
"PM10": 45, "PM2.5": 15, "NO2": 10, "O3": 100, "Benzene": 5
}
}
Auth Endpoint
Request Body
Request{ "username": "admin", "password": "<password>" }
Response
Response 200{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": { "id": 1, "username": "admin", "role": "admin" }
}
Response 401
{ "error": "Invalid credentials" }
Django equivalent (SimpleJWT)
# urls.py
path('api/v1/auth/login/', TokenObtainPairView.as_view())
Private Endpoints JWT Required
Returns hourly pollutant data enriched with all computed indices. Each row gets ICN (5 components + synthetic), IPP, IRSR (5 components + synthetic), and ISIT (3 families + composite).
Parameters
| Param | Type | Description |
|---|---|---|
| id required | integer | Station ID |
| from optional | date | Start date (default: 7 days ago) |
| to optional | date | End date (default: today) |
| limit optional | integer | Max rows (default: 500, max: 5000) |
Response
Response 200{
"data": [
{
"data_ora": "2026-04-07 12:00:00",
"pm10": 28.5, "pm25": 14.2,
"no2": 35.8, "o3": 72.1, "benzene": 0.95,
"icn_pm10": 0.57, "icn_pm25": 0.568,
"icn_no2": 0.179, "icn_o3": 0.6008,
"icn_benzene": 0.19, "icn_sintetico": 0.4215,
"ipp": 0.6262,
"irsr_pm10": 0.6333, "irsr_pm25": 0.9467,
"irsr_no2": 3.58, "irsr_o3": 0.721,
"irsr_benzene": 0.0002, "irsr_sintetico": 1.1762,
"isit_particolato": 57.1,
"isit_gas_reattivi": 38.94,
"isit_voc": 19.0,
"isit_complessivo": 0.4222
}
],
"count": 168
}
Raw meteo data enriched with 10 computed indices plus the IPRP risk score. Data ordered ASC (oldest first) because pressure_delta needs the previous row.
Parameters
| Param | Type | Description |
|---|---|---|
| from optional | date | Start date (default: 7 days ago) |
| to optional | date | End date (default: today) |
| limit optional | integer | Max rows (default: 500, max: 5000) |
Response
Response 200{
"data": [
{
"time": "2026-04-07 12:00:00",
"temp": 18.5, "dwpt": 12.3, "rhum": 67,
"wspd": 14.4, "pres": 1013.2,
"dew_point_deficit": 6.2,
"air_saturation": 0.3351,
"wind_energy": 103.68,
"turbulence": 13.7,
"stagnation": 4.6207,
"pressure_delta": -0.3,
"evaporation": 3.9761,
"ventilation": 74.88,
"iprp": 0.45,
"classe_rischio": "Basso",
"prob_picco_pm": 0.2,
"livello_allerta": "Nessuna criticita"
}
],
"count": 168
}
Runs the linear regression forecast model on historical data and returns predictions alongside observed values with error percentages.
Parameters
| Param | Type | Description |
|---|---|---|
| id optional | integer | Station ID (default: 2 = LM03) |
| from optional | date | Start date (default: 3 days ago) |
| to optional | date | End date (default: today) |
Response
Response 200{
"data": [
{
"data_ora": "2026-04-07 10:00:00",
"observed": { "pm10": 31.2, "pm25": 15.8, "no2": 42.1 },
"prev_pm10": 29.85,
"prev_pm25": 14.32,
"prev_no2": 38.91,
"traffic_index": 5,
"hour_sin": 0.866,
"is_weekend": 0,
"error_pct_pm10": 4.33,
"error_pct_pm25": 9.37,
"error_pct_no2": 7.58
}
],
"model": {
"PM10": { "c1": 0.55, "c2": 0.15, "traffic": 0.10, "wind": -1.2, "rain": -0.8, "humidity": 0.06, "temp": 0, "intercept": 8 },
"PM2.5": { "c1": 0.60, "c2": 0.20, "traffic": 0.08, "wind": -0.9, "rain": -0.5, "humidity": 0.05, "temp": 0.03, "intercept": 5 },
"NO2": { "c1": 0.50, "c2": 0.20, "traffic": 0.18, "wind": -1.0, "rain": 0, "humidity": 0.03, "temp": -0.04, "intercept": 6 }
},
"count": 70
}
Computes the IDS (Divergence Index) across all 6 active stations for each day, plus monthly summaries.
Parameters
| Param | Type | Description |
|---|---|---|
| from optional | date | Start date (default: 30 days ago) |
| to optional | date | End date (default: today) |
| param optional | string | pm10 or pm25 (default: pm10) |
Response
Response 200{
"daily": [
{
"data": "2026-04-07",
"values": {
"MMB_274": 27.8, "LM03": 23.4,
"AR_CO_ST_1": 19.2, "AR_CO_ST_3": 21.6,
"AR_CO_ST_4": 18.3, "AR_CO_ST_5": 25.1
},
"ids": 0.1542,
"classe": "contenuta",
"fonte_max": "MMB_274",
"segnale": "Sorveglianza ordinaria"
}
],
"monthly": [
{ "mese": "2026-04", "ids_medio": 0.1823 }
],
"param": "pm10"
}
Pearson correlation coefficients between all station pairs for both PM10 and PM2.5.
Parameters
| Param | Type | Description |
|---|---|---|
| from optional | date | Start date (default: 90 days ago) |
| to optional | date | End date (default: today) |
Response
Response 200{
"pm10": [
{ "station1": "MMB_274", "station2": "LM03", "r": 0.8921, "n": 85 },
{ "station1": "MMB_274", "station2": "ST_1", "r": 0.7543, "n": 78 }
],
"pm25": [
{ "station1": "MMB_274", "station2": "LM03", "r": 0.9102, "n": 82 }
]
}
Daily vehicle count data from the trasporti table (bagnoli_ambiente schema).
Parameters
| Param | Type | Description |
|---|---|---|
| from optional | date | Start date (default: 90 days ago) |
| to optional | date | End date (default: today) |
Response
Response 200{
"data": [
{ "data": "2026-04-07", "totale_generale": 4850 }
]
}
Hourly data from Invitalia stations (bagnoli_ambiente schema). Only PM10 data (no gas sensors).
Parameters
| Param | Type | Description |
|---|---|---|
| id optional | integer | Station ID (7-11). Omit for all. |
| from optional | date | Start date (default: 30 days ago) |
| to optional | date | End date (default: today) |
| limit optional | integer | Max rows (default: 500, max: 5000) |
Response
Response 200{
"data": [
{
"id_centralina": 7,
"stazione": "AR_CO_ST_1",
"data_ora": "2026-04-07 14:00:00",
"pm10_orario": 22.1,
"pm10_giornaliero": 19.8
}
]
}
Insert data manually into allowed tables. Only whitelisted fields accepted. Auto-adds timestamps.
Allowed Tables & Fields
| Table | Schema | Fields |
|---|---|---|
| invitalia_dati | amb | id_centralina, data_campionamento, pm10, codice_filtro_pm10, pm2_5, codice_filtro_pm2_5, note, stato |
| centraline_invitalia | amb | id_centralina, stazione, data_ora, pm10_orario, pm10_giornaliero |
| opas_ispra_1793 | dev | id_centralina, data_ora, so2, o3, no2, co, pm2_5_fidas, pm10_fidas, benzene, stato |
| opas_ispra_1798 | dev | same as 1793 |
Request
Request{
"table": "centraline_invitalia",
"data": {
"id_centralina": 7,
"stazione": "AR_CO_ST_1",
"data_ora": "2026-04-07 14:00:00",
"pm10_orario": 23.5
}
}
Response 201
{ "success": true, "table": "centraline_invitalia", "id": 45232 }
Create a new monitoring station. Also creates location entry if lat/lng provided.
Request
Request (POST){
"name": "Nuova Stazione Test",
"alias": "TEST_01",
"tipo": "manuale",
"status": "active",
"colore_mappa": "#FF5722",
"lat": 40.815,
"lng": 14.170,
"nome_comune": "Napoli"
}
Response 201
{ "success": true, "id": 12 }
Same as centraline but requires JWT. Returns all stations with location data.
Parameters
| Param | Type | Description |
|---|---|---|
| type optional | string | dati (default), meteo, invitalia, transport |
| format optional | string | json (default) or csv |
| id optional | integer | Station ID (for dati/invitalia) |
| from optional | date | Start date (default: 30 days ago) |
| to optional | date | End date (default: today) |
Content-Type: text/csv with Content-Disposition: attachment; filename="export_{type}.csv"
Django Project Structure
centraline_backend/
manage.py
config/
settings.py # Dual DB + JWT config
urls.py # API router
db_router.py # Routes models to correct schema
apps/
stations/
models.py # Centralina, CentralinaLocalizzazione
serializers.py
views.py # CentralinaViewSet
ispra/
models.py # OpasIspra1793, OpasIspra1798
serializers.py
views.py
meteo/
models.py # MeteostatnetDati
serializers.py
views.py
invitalia/
models.py # CentralineInvitalia, InvitaliaDati, Trasporti
serializers.py
views.py
indices/
services.py # IndicesCalculator, MeteoCalculator, ForecastService
views.py # IndicesView, MeteoIndicesView, ForecastView
reports/
services.py # WeeklyReportService, DivergenceService
views.py # WeeklyReportView, PM10AllReportView, DashboardView
Settings & Database Router
config/settings.pyDATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'HOST': 'hetzner-dbserver-dev.sviluppo-sw.it',
'PORT': '5432',
'NAME': 'devbagnolicrm',
'USER': 'devbagnolicrm',
'OPTIONS': {
'options': '-c search_path=bagnoli_ambiente_dev',
},
},
'ambiente': {
'ENGINE': 'django.db.backends.postgresql',
'HOST': 'hetzner-dbserver-dev.sviluppo-sw.it',
'PORT': '5432',
'NAME': 'devbagnolicrm',
'USER': 'devbagnolicrm',
'OPTIONS': {
'options': '-c search_path=bagnoli_ambiente',
},
},
}
DATABASE_ROUTERS = ['config.db_router.AmbienteRouter']
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.AllowAny',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'PAGE_SIZE': 500,
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=24),
'ALGORITHM': 'HS256',
}
config/db_router.py
class AmbienteRouter:
"""Routes Invitalia and Transport models to 'ambiente' DB."""
AMBIENTE_MODELS = {'centralineinvitalia', 'invitaliadati', 'trasporti'}
def db_for_read(self, model, **hints):
if model._meta.model_name in self.AMBIENTE_MODELS:
return 'ambiente'
return 'default'
def db_for_write(self, model, **hints):
if model._meta.model_name in self.AMBIENTE_MODELS:
return 'ambiente'
return 'default'
def allow_relation(self, obj1, obj2, **hints):
return True
def allow_migrate(self, db, app_label, model_name=None, **hints):
if model_name in self.AMBIENTE_MODELS:
return db == 'ambiente'
return db == 'default'
Models
apps/stations/models.pyfrom django.db import models
class Centralina(models.Model):
name = models.TextField()
alias = models.TextField(blank=True, null=True)
codice = models.TextField(blank=True, null=True)
tipo = models.TextField(blank=True, null=True)
external_id = models.TextField(blank=True, null=True)
url = models.TextField(blank=True, null=True)
status = models.TextField(default='active')
colore_mappa = models.TextField(blank=True, null=True)
is_manuale = models.BooleanField(default=False)
raggio_mappa = models.IntegerField(null=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'centralina'
managed = False
class CentralinaLocalizzazione(models.Model):
id_centralina = models.ForeignKey(
Centralina, db_column='id_centralina', on_delete=models.CASCADE
)
lat = models.DecimalField(max_digits=10, decimal_places=6)
lng = models.DecimalField(max_digits=10, decimal_places=6)
nome_comune = models.TextField(null=True)
nome_provincia = models.TextField(null=True)
data_inizio = models.DateField(null=True)
stato = models.IntegerField(default=1)
class Meta:
db_table = 'centralina_localizzazione'
managed = False
apps/ispra/models.py
class OpasIspra1793(models.Model):
"""Station MMB_274 (id=1)"""
id_centralina = models.IntegerField()
data_ora = models.DateTimeField()
so2 = models.DecimalField(max_digits=10, decimal_places=4, null=True)
o3 = models.DecimalField(max_digits=10, decimal_places=4, null=True)
no2 = models.DecimalField(max_digits=10, decimal_places=4, null=True)
co = models.DecimalField(max_digits=10, decimal_places=4, null=True)
pm2_5_fidas = models.DecimalField(max_digits=10, decimal_places=4, null=True)
pm10_fidas = models.DecimalField(max_digits=10, decimal_places=4, null=True)
benzene = models.DecimalField(max_digits=10, decimal_places=4, null=True)
stato = models.IntegerField(null=True)
class Meta:
db_table = 'opas_ispra_1793'
managed = False
# OpasIspra1798 is identical structure with db_table = 'opas_ispra_1798'
apps/meteo/models.py
class MeteostatnetDati(models.Model):
id_centralina = models.IntegerField(null=True)
time = models.DateTimeField()
temp = models.DecimalField(max_digits=6, decimal_places=2, null=True)
dwpt = models.DecimalField(max_digits=6, decimal_places=2, null=True)
rhum = models.DecimalField(max_digits=6, decimal_places=2, null=True)
prcp = models.DecimalField(max_digits=6, decimal_places=2, null=True)
snow = models.DecimalField(max_digits=6, decimal_places=2, null=True)
wdir = models.DecimalField(max_digits=6, decimal_places=2, null=True)
wspd = models.DecimalField(max_digits=6, decimal_places=2, null=True)
wpgt = models.DecimalField(max_digits=6, decimal_places=2, null=True)
pres = models.DecimalField(max_digits=8, decimal_places=2, null=True)
tsun = models.DecimalField(max_digits=6, decimal_places=2, null=True)
coco = models.IntegerField(null=True)
class Meta:
db_table = 'meteostatnet_dati'
managed = False
apps/invitalia/models.py
class CentralineInvitalia(models.Model):
"""Routed to 'ambiente' DB via AmbienteRouter"""
id_centralina = models.IntegerField()
stazione = models.TextField(null=True)
data_ora = models.DateTimeField()
pm10_orario = models.DecimalField(max_digits=10, decimal_places=4, null=True)
pm10_giornaliero = models.DecimalField(max_digits=10, decimal_places=4, null=True)
class Meta:
db_table = 'centraline_invitalia'
managed = False
class Trasporti(models.Model):
data = models.DateField()
totale_generale = models.DecimalField(max_digits=10, decimal_places=2, null=True)
class Meta:
db_table = 'trasporti'
managed = False
Serializers
apps/stations/serializers.pyfrom rest_framework import serializers
class CentralinaSerializer(serializers.ModelSerializer):
lat = serializers.DecimalField(source='active_location.lat', read_only=True,
max_digits=10, decimal_places=6)
lng = serializers.DecimalField(source='active_location.lng', read_only=True,
max_digits=10, decimal_places=6)
nome_comune = serializers.CharField(source='active_location.nome_comune',
read_only=True)
nome_provincia = serializers.CharField(source='active_location.nome_provincia',
read_only=True)
class Meta:
model = Centralina
fields = '__all__'
Views / ViewSets
apps/indices/views.pyfrom rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .services import IndicesCalculator
class IndicesView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
station_id = int(request.query_params.get('id', 0))
from_date = request.query_params.get('from')
to_date = request.query_params.get('to')
hourly = get_hourly_full(station_id, from_date, to_date)
calc = IndicesCalculator()
enriched = []
for row in hourly:
r = normalize_row(row)
result = {**row}
result.update(calc.compute_icn(r))
result['ipp'] = calc.compute_ipp(r.get('pm10'), r.get('pm25'))
result.update(calc.compute_irsr(r))
result.update(calc.compute_isit(r))
enriched.append(result)
return Response({'data': enriched, 'count': len(enriched)})
URLs & Router
config/urls.pyfrom django.urls import path, include
from rest_framework.routers import DefaultRouter
from rest_framework_simplejwt.views import TokenObtainPairView
router = DefaultRouter()
router.register('centraline', CentralinaViewSet)
urlpatterns = [
path('api/v1/', include(router.urls)),
# Auth
path('api/v1/auth/login/', TokenObtainPairView.as_view()),
# Public
path('api/v1/ispra/', IspraListView.as_view()),
path('api/v1/meteo/', MeteoListView.as_view()),
path('api/v1/latest/', LatestView.as_view()),
path('api/v1/stats/', StatsView.as_view()),
path('api/v1/report-weekly/', WeeklyReportView.as_view()),
path('api/v1/report-pm10-all/', PM10AllReportView.as_view()),
path('api/v1/dashboard/', DashboardView.as_view()),
path('api/v1/normativa/', NormativaView.as_view()),
# Private (JWT)
path('api/v1/indices/', IndicesView.as_view()),
path('api/v1/meteo-indices/', MeteoIndicesView.as_view()),
path('api/v1/forecast/', ForecastView.as_view()),
path('api/v1/divergence/', DivergenceView.as_view()),
path('api/v1/correlation/', CorrelationView.as_view()),
path('api/v1/transport/', TransportView.as_view()),
path('api/v1/invitalia/', InvitaliaView.as_view()),
path('api/v1/manual-entry/', ManualEntryView.as_view()),
path('api/v1/config/', ConfigView.as_view()),
path('api/v1/export/', ExportView.as_view()),
path('api/v1/stations/', StationsView.as_view()),
]
Angular Project Structure
centraline-frontend/
src/app/
core/
services/
centraline.service.ts # All API calls
auth.service.ts # Login, token management
interceptors/
auth.interceptor.ts # Attaches JWT to requests
guards/
auth.guard.ts # Protects private routes
models/
centralina.model.ts
indices.model.ts
features/
dashboard/
dashboard.component.ts
report/
weekly-report.component.ts
pm10-all-report.component.ts
indices/
indices.component.ts
meteo-indices.component.ts
forecast/
forecast.component.ts
divergence/
divergence.component.ts
correlation/
correlation.component.ts
stations/
station-map.component.ts
auth/
login.component.ts
app.routes.ts
app.config.ts
Services
core/services/centraline.service.tsimport { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class CentralineService {
private http = inject(HttpClient);
private base = '/api/v1';
// ===== PUBLIC =====
getCentraline() { return this.http.get(`${this.base}/centraline/`); }
getCentralina(id: number) { return this.http.get(`${this.base}/centraline/${id}/`); }
getMeteo(from?: string, to?: string): Observable<any> {
let p = new HttpParams();
if (from) p = p.set('from', from);
if (to) p = p.set('to', to);
return this.http.get(`${this.base}/meteo/`, { params: p });
}
getIspra(id?: number, from?: string, to?: string): Observable<any> {
let p = new HttpParams();
if (id) p = p.set('id', id);
if (from) p = p.set('from', from);
if (to) p = p.set('to', to);
return this.http.get(`${this.base}/ispra/`, { params: p });
}
getLatest() { return this.http.get(`${this.base}/latest/`); }
getStats(days = 30) { return this.http.get(`${this.base}/stats/`, { params: { days } }); }
getNormativa() { return this.http.get(`${this.base}/normativa/`); }
getWeeklyReport(id: number, year?: number): Observable<any> {
let p = new HttpParams().set('id', id);
if (year) p = p.set('year', year);
return this.http.get(`${this.base}/report-weekly/`, { params: p });
}
getPM10AllReport(from?: string, to?: string): Observable<any> {
let p = new HttpParams();
if (from) p = p.set('from', from);
if (to) p = p.set('to', to);
return this.http.get(`${this.base}/report-pm10-all/`, { params: p });
}
getDashboard(week?: string): Observable<any> {
let p = new HttpParams();
if (week) p = p.set('week', week);
return this.http.get(`${this.base}/dashboard/`, { params: p });
}
// ===== PRIVATE (JWT auto-attached by interceptor) =====
getIndices(id: number, from?: string, to?: string): Observable<any> {
let p = new HttpParams().set('id', id);
if (from) p = p.set('from', from);
if (to) p = p.set('to', to);
return this.http.get(`${this.base}/indices/`, { params: p });
}
getMeteoIndices(from?: string, to?: string): Observable<any> {
let p = new HttpParams();
if (from) p = p.set('from', from);
if (to) p = p.set('to', to);
return this.http.get(`${this.base}/meteo-indices/`, { params: p });
}
getForecast(id?: number, from?: string, to?: string): Observable<any> {
let p = new HttpParams();
if (id) p = p.set('id', id);
if (from) p = p.set('from', from);
if (to) p = p.set('to', to);
return this.http.get(`${this.base}/forecast/`, { params: p });
}
getDivergence(from?: string, to?: string, param = 'pm10'): Observable<any> {
let p = new HttpParams().set('param', param);
if (from) p = p.set('from', from);
if (to) p = p.set('to', to);
return this.http.get(`${this.base}/divergence/`, { params: p });
}
getCorrelation(from?: string, to?: string): Observable<any> {
let p = new HttpParams();
if (from) p = p.set('from', from);
if (to) p = p.set('to', to);
return this.http.get(`${this.base}/correlation/`, { params: p });
}
getTransport(from?: string, to?: string): Observable<any> {
let p = new HttpParams();
if (from) p = p.set('from', from);
if (to) p = p.set('to', to);
return this.http.get(`${this.base}/transport/`, { params: p });
}
getInvitalia(id?: number, from?: string, to?: string): Observable<any> {
let p = new HttpParams();
if (id) p = p.set('id', id);
if (from) p = p.set('from', from);
if (to) p = p.set('to', to);
return this.http.get(`${this.base}/invitalia/`, { params: p });
}
postManualEntry(table: string, data: any): Observable<any> {
return this.http.post(`${this.base}/manual-entry/`, { table, data });
}
postStation(stationData: any): Observable<any> {
return this.http.post(`${this.base}/stations/`, stationData);
}
getConfig() { return this.http.get(`${this.base}/config/`); }
exportData(type: string, format: string, from?: string, to?: string): Observable<any> {
let p = new HttpParams().set('type', type).set('format', format);
if (from) p = p.set('from', from);
if (to) p = p.set('to', to);
const opts = format === 'csv' ? { params: p, responseType: 'blob' as 'json' } : { params: p };
return this.http.get(`${this.base}/export/`, opts);
}
}
core/services/auth.service.ts
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { tap } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class AuthService {
private http = inject(HttpClient);
private TOKEN_KEY = 'centraline_jwt';
isAuthenticated = signal(this.hasToken());
currentUser = signal<any>(null);
login(username: string, password: string) {
return this.http.post('/api/v1/auth/login/', { username, password })
.pipe(tap((res: any) => {
localStorage.setItem(this.TOKEN_KEY, res.token);
this.isAuthenticated.set(true);
this.currentUser.set(res.user);
}));
}
logout() {
localStorage.removeItem(this.TOKEN_KEY);
this.isAuthenticated.set(false);
this.currentUser.set(null);
}
getToken(): string | null {
return localStorage.getItem(this.TOKEN_KEY);
}
private hasToken(): boolean {
return !!localStorage.getItem(this.TOKEN_KEY);
}
}
Components
features/dashboard/dashboard.component.tsimport { Component, inject, OnInit, signal } from '@angular/core';
import { CentralineService } from '../../core/services/centraline.service';
@Component({
selector: 'app-dashboard',
standalone: true,
template: `
<div class="dashboard">
<h1>Dashboard Settimanale</h1>
<input type="week" [value]="selectedWeek()"
(change)="onWeekChange($event)" />
@if (loading()) {
<div class="spinner">Loading...</div>
} @else {
@for (report of data()?.reports; track report.centralina_id) {
<app-station-card [report]="report"
[kpi]="getKpi(report.centralina_id)" />
}
}
</div>
`
})
export class DashboardComponent implements OnInit {
private svc = inject(CentralineService);
selectedWeek = signal('');
data = signal<any>(null);
loading = signal(false);
ngOnInit() { this.loadDashboard(); }
onWeekChange(event: Event) {
const val = (event.target as HTMLInputElement).value;
this.selectedWeek.set(val);
this.loadDashboard(val);
}
private loadDashboard(week?: string) {
this.loading.set(true);
this.svc.getDashboard(week).subscribe(res => {
this.data.set(res);
this.loading.set(false);
});
}
getKpi(stationId: number) {
return this.data()?.kpis?.find((k: any) => k.centralina_id === stationId);
}
}
Routing & Guards
app.routes.tsimport { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';
export const routes: Routes = [
// Public
{ path: '', loadComponent: () =>
import('./features/dashboard/dashboard.component')
.then(m => m.DashboardComponent) },
{ path: 'report/:id', loadComponent: () =>
import('./features/report/weekly-report.component')
.then(m => m.WeeklyReportComponent) },
{ path: 'pm10', loadComponent: () =>
import('./features/report/pm10-all-report.component')
.then(m => m.PM10AllReportComponent) },
{ path: 'login', loadComponent: () =>
import('./features/auth/login.component')
.then(m => m.LoginComponent) },
// Private (guarded)
{ path: 'indices', canActivate: [authGuard], loadComponent: () =>
import('./features/indices/indices.component')
.then(m => m.IndicesComponent) },
{ path: 'forecast', canActivate: [authGuard], loadComponent: () =>
import('./features/forecast/forecast.component')
.then(m => m.ForecastComponent) },
{ path: 'divergence', canActivate: [authGuard], loadComponent: () =>
import('./features/divergence/divergence.component')
.then(m => m.DivergenceComponent) },
{ path: 'correlation', canActivate: [authGuard], loadComponent: () =>
import('./features/correlation/correlation.component')
.then(m => m.CorrelationComponent) },
];
core/guards/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from '../services/auth.service';
export const authGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isAuthenticated()) return true;
return router.createUrlTree(['/login']);
};
Interceptors
core/interceptors/auth.interceptor.tsimport { HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '../services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const token = inject(AuthService).getToken();
if (token) {
req = req.clone({
setHeaders: { Authorization: `Bearer ${token}` }
});
}
return next(req);
};
app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withInterceptors([authInterceptor])),
]
};
api/index.php, api/indices.php, api/config.php. All formulas verified against Excel convertire.xlsm.