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

Target Stack (Migration)

Base URL

// Current PHP
https://<host>/api/?action=<endpoint>&param=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.

JWT Details: Algorithm HS256. Payload contains 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

ColumnTypeNotes
idSERIAL PKAuto-increment primary key
nameTEXTDisplay name
aliasTEXTShort code (e.g. LM03, MMB_274)
codiceTEXTOfficial station code
tipoTEXTStation type (ISPRA, Invitalia)
external_idTEXTExternal reference ID
urlTEXTData source URL
statusTEXTActive/inactive
colore_mappaTEXTMap marker color hex
is_manualeBOOLEANManual data entry station
raggio_mappaINTEGERMap radius in meters
createdTIMESTAMP
updatedTIMESTAMP

Table: centralina_localizzazione

ColumnTypeNotes
idSERIAL PK
id_centralinaINTEGER FKReferences centralina.id
latNUMERICLatitude
lngNUMERICLongitude
nome_comuneTEXTMunicipality name
nome_provinciaTEXTProvince name
data_inizioDATELocation start date
statoINTEGER1 = active location
createdTIMESTAMP
updatedTIMESTAMP

Table: opas_ispra_1793 (Station MMB_274, id_centralina=1)

ColumnTypeNotes
idSERIAL PK
id_centralinaINTEGERAlways 1 for this table
data_oraTIMESTAMPHourly reading timestamp
so2NUMERICSulfur dioxide (ug/m3)
o3NUMERICOzone (ug/m3)
no2NUMERICNitrogen dioxide (ug/m3)
coNUMERICCarbon monoxide (mg/m3)
pm2_5_fidasNUMERICPM2.5 (ug/m3)
pm10_fidasNUMERICPM10 (ug/m3)
benzeneNUMERICBenzene (ug/m3). See note on old/new format
statoINTEGER

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

ColumnTypeNotes
idSERIAL PK
id_centralinaINTEGER
timeTIMESTAMPObservation time
tempNUMERICTemperature (C)
dwptNUMERICDew point (C)
rhumNUMERICRelative humidity (%)
prcpNUMERICPrecipitation (mm)
snowNUMERICSnow (mm)
wdirNUMERICWind direction (degrees)
wspdNUMERICWind speed (km/h)
wpgtNUMERICWind gust (km/h)
presNUMERICAtmospheric pressure (hPa)
tsunNUMERICSunshine duration (hours)
cocoINTEGERWeather condition code

Schema: bagnoli_ambiente

Table: centraline_invitalia

ColumnTypeNotes
idSERIAL PK
id_centralinaINTEGER7=ST.1, 8=ST.3, 9=ST.4, 10=ST.5, 11=ST.2
stazioneTEXTStation name string
data_oraTIMESTAMPHourly observation time
pm10_orarioNUMERICHourly PM10 (ug/m3)
pm10_giornalieroNUMERICDaily PM10 (ug/m3)

Table: trasporti

ColumnTypeNotes
dataDATEDate
totale_generaleNUMERICTotal vehicles count
additional columnsNUMERICPer-category breakdowns

Table: invitalia_dati

ColumnTypeNotes
idSERIAL PK
id_centralinaINTEGER
data_campionamentoDATESampling date
pm10NUMERICManual PM10 measurement
codice_filtro_pm10TEXTFilter code for PM10
pm2_5NUMERICManual PM2.5 measurement
codice_filtro_pm2_5TEXTFilter code for PM2.5
noteTEXT
statoINTEGER

SQLite: auth.sqlite

Table: users

ColumnTypeNotes
idINTEGER PKAuto-increment
usernameTEXT UNIQUE
passwordTEXTbcrypt hash
roleTEXTDefault: "user"
created_atDATETIME

Station ID Mapping

IDCodeNameTypeTableSchema
1MMB_274Museo/Pontile NordISPRAopas_ispra_1793bagnoli_ambiente_dev
2LM03Citta della ScienzaISPRAopas_ispra_1798bagnoli_ambiente_dev
7AR_CO_ST_1Stazione 1Invitaliacentraline_invitaliabagnoli_ambiente
8AR_CO_ST_3Stazione 3Invitaliacentraline_invitaliabagnoli_ambiente
9AR_CO_ST_4Stazione 4Invitaliacentraline_invitaliabagnoli_ambiente
10AR_CO_ST_5Stazione 5Invitaliacentraline_invitaliabagnoli_ambiente
11AR_CO_ST_2Stazione 2Invitaliacentraline_invitaliabagnoli_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
Critical null handling: When a pollutant value is null, ICN treats it as 0 (not excluded). The divisor is ALWAYS 5, even if some components are null. This replicates Excel's IFERROR(value/limit, "") where empty cells become 0 in the AVERAGE.
Benzene old/new format: Before 2026-02-19 15:00, benzene values in the DB were stored in their raw form (100-1000x larger). Values > 5 are old format and need (value/1000)/5. Values ≤ 5 are new format and just need value/5.
Django equivalent
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%
Django equivalent
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)
Key difference from ICN: ICN always divides by 5 (null = 0). IRSR divides by the count of components that have real data, EXCEPT benzene which is always included as 0 when null.
Django equivalent
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

GET /api/?action=centraline List all monitoring stations

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
GET /api/?action=centralina&id=X Single station detail

Parameters

ParamTypeDescription
id requiredintegerStation 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" }
GET /api/?action=meteo&from=&to= Weather data

Raw hourly meteorological data from meteostatnet_dati table.

Parameters

ParamTypeDescription
from optionaldateStart date (default: 7 days ago)
to optionaldateEnd date (default: today)
limit optionalintegerMax 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 }
}
GET /api/?action=ispra&id=X&from=&to= ISPRA pollutant data

Hourly pollutant readings from ISPRA stations. When id is omitted, data from both stations is merged and sorted by timestamp.

Parameters

ParamTypeDescription
id optionalinteger1 = MMB_274 (opas_ispra_1793), 2 = LM03 (opas_ispra_1798). Omit for both.
from optionaldateStart date (default: 7 days ago)
to optionaldateEnd date (default: today)
limit optionalintegerMax 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" }
}
Data routing: id=1 queries opas_ispra_1793, id=2 queries opas_ispra_1798. Without id, both tables are queried and results merged.
GET /api/?action=latest Latest readings from all sources

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
    }
  ]
}
GET /api/?action=stats&days=N Aggregate statistics

Parameters

ParamTypeDescription
days optionalintegerLookback 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 }
}
GET /api/?action=report-weekly&id=X&year=Y Weekly report per station

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

ParamTypeDescription
id requiredintegerStation ID
year optionalintegerYear (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 }
}
Performance: Pre-loads ALL yearly data in one query, then slices per week in-memory. Indices pre-computed once to avoid 52xN DB roundtrips.
GET /api/?action=report-pm10-all&from=&to= Daily PM10 cross-station

Daily PM10 averages for ALL stations side-by-side, plus transport data. Replicates Excel sheet REPORT-PM10_ALL.

Parameters

ParamTypeDescription
from optionaldateStart date (default: Jan 1 current year)
to optionaldateEnd 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" }
}
GET /api/?action=dashboard&week=2026-W14 Full dashboard data

Complete dashboard payload for a given ISO week. Includes weekly reports for ALL 7 stations, KPI composites, meteo averages, and transport averages.

Parameters

ParamTypeDescription
week optionalstringISO 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 }
}
Station order (same as Excel): LM03 (2), MMB274 (1), ST.1 (7), ST.2 (11), ST.3 (8), ST.4 (9), ST.5 (10)
GET /api/?action=normativa Regulatory limits

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

POST /api/?action=login JWT authentication

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

GET /api/?action=indices&id=X&from=&to= Computed indices (ICN, IPP, IRSR, ISIT)

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

ParamTypeDescription
id requiredintegerStation ID
from optionaldateStart date (default: 7 days ago)
to optionaldateEnd date (default: today)
limit optionalintegerMax 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
}
GET /api/?action=meteo-indices&from=&to= Weather indices + IPRP

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

ParamTypeDescription
from optionaldateStart date (default: 7 days ago)
to optionaldateEnd date (default: today)
limit optionalintegerMax 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
}
GET /api/?action=forecast&from=&to=&id=X Pollutant forecast model

Runs the linear regression forecast model on historical data and returns predictions alongside observed values with error percentages.

Parameters

ParamTypeDescription
id optionalintegerStation ID (default: 2 = LM03)
from optionaldateStart date (default: 3 days ago)
to optionaldateEnd 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
}
GET /api/?action=divergence&from=&to=&param=pm10 Inter-station divergence

Computes the IDS (Divergence Index) across all 6 active stations for each day, plus monthly summaries.

Parameters

ParamTypeDescription
from optionaldateStart date (default: 30 days ago)
to optionaldateEnd date (default: today)
param optionalstringpm10 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"
}
GET /api/?action=correlation&from=&to= Station correlation matrix

Pearson correlation coefficients between all station pairs for both PM10 and PM2.5.

Parameters

ParamTypeDescription
from optionaldateStart date (default: 90 days ago)
to optionaldateEnd 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 }
  ]
}
GET /api/?action=transport&from=&to= Transport data

Daily vehicle count data from the trasporti table (bagnoli_ambiente schema).

Parameters

ParamTypeDescription
from optionaldateStart date (default: 90 days ago)
to optionaldateEnd date (default: today)

Response

Response 200
{
  "data": [
    { "data": "2026-04-07", "totale_generale": 4850 }
  ]
}
GET /api/?action=invitalia&id=X&from=&to= Invitalia station data

Hourly data from Invitalia stations (bagnoli_ambiente schema). Only PM10 data (no gas sensors).

Parameters

ParamTypeDescription
id optionalintegerStation ID (7-11). Omit for all.
from optionaldateStart date (default: 30 days ago)
to optionaldateEnd date (default: today)
limit optionalintegerMax 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
    }
  ]
}
POST /api/?action=manual-entry Manual data entry

Insert data manually into allowed tables. Only whitelisted fields accepted. Auto-adds timestamps.

Allowed Tables & Fields

TableSchemaFields
invitalia_datiambid_centralina, data_campionamento, pm10, codice_filtro_pm10, pm2_5, codice_filtro_pm2_5, note, stato
centraline_invitaliaambid_centralina, stazione, data_ora, pm10_orario, pm10_giornaliero
opas_ispra_1793devid_centralina, data_ora, so2, o3, no2, co, pm2_5_fidas, pm10_fidas, benzene, stato
opas_ispra_1798devsame 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 }
POST /api/?action=stations Add new station

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 }
GET /api/?action=config Station configuration

Same as centraline but requires JWT. Returns all stations with location data.

GET /api/?action=export&type=X&format=csv Data export

Parameters

ParamTypeDescription
type optionalstringdati (default), meteo, invitalia, transport
format optionalstringjson (default) or csv
id optionalintegerStation ID (for dati/invitalia)
from optionaldateStart date (default: 30 days ago)
to optionaldateEnd date (default: today)
CSV mode: Returns 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.py
DATABASES = {
    '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.py
from 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.py
from 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.py
from 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.py
from 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.ts
import { 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.ts
import { 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.ts
import { 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.ts
import { 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])),
  ]
};
Centraline Ambientali API Documentation v2.0 -- Generated from source code analysis of api/index.php, api/indices.php, api/config.php. All formulas verified against Excel convertire.xlsm.