Skip to main content

Site Quantities — Calculation & Determination Logic

This document explains how every field on the /chemiq/site-quantities screen is calculated, including the Tier II threshold, Fire Code threshold, and every column in the Hazard Summary tab.


1. High-Level Architecture

Inventory Items (chemiq_inventory)


Background Service (SQS message: quantity_recalculation)

├─ Step 1: Load reference data (units, densities, fire thresholds)
├─ Step 2: Aggregate inventory by CAS number
├─ Step 3: Upsert chemiq_site_chemical_summary (Chemical Summary tab)
├─ Step 4: Aggregate by hazard class → chemiq_site_hazard_summary (Hazard Summary tab)
└─ Step 5: Check thresholds, generate alerts


Frontend reads from the two summary tables via API

Trigger: The recalculation is triggered by clicking the "Recalculate" button on the Site Quantities page, which dispatches an SQS message to the background service (tellus-ehs-background-service). The service class is QuantityAggregationService in app/services/quantity_aggregation/service.py.


2. Data Sources (Reference Tables)

All three reference tables are static seed data populated via SQL INSERT statements defined in docs/features/site_chemical_quantity_aggregation.md. They are not user-editable and do not change during normal application use.

2.1 Unit Conversions (chemiq_unit_conversions)

All inventory quantities are normalized to pounds (lbs) for weight and gallons (gal) for volume using conversion factors:

UnitTypeFactorStandard Unit
lbs / lbweight1.0lbs
ozweight0.0625lbs
kgweight2.2046lbs
gweight0.0022lbs
mgweight0.0000022lbs
tonweight2000.0lbs
galvolume1.0gal
Lvolume0.2642gal
qtvolume0.25gal
ptvolume0.125gal
fl_ozvolume0.0078gal
mLvolume0.000264gal
cu_ftvolume7.4805gal

Data source: Standard scientific unit conversion factors. Seeded via SQL in docs/features/site_chemical_quantity_aggregation.md (lines 231–248). These are universal constants that do not require updates.

2.2 Product Densities (chemiq_product_density)

Used to convert between weight and volume. Stores density_lbs_per_gal per CAS number. Default fallback: 8.34 lbs/gal (water density).

Data source: Seeded with a single baseline record — water (CAS 7732-18-5, 8.34 lbs/gal). Additional densities can be populated from SDS data, PubChem, or manual entry (tracked by the data_source column: 'SDS', 'PubChem', 'Reference', or 'Manual'). If no density record exists for a CAS number, the background service falls back to the water density constant (DEFAULT_DENSITY = 8.34) hardcoded in QuantityAggregationService.

Seed SQL: docs/features/site_chemical_quantity_aggregation.md (lines 283–284).

2.3 Fire Code Thresholds (chemiq_fire_code_thresholds)

Reference data based on International Fire Code (IFC) Table 5003.1.1(1) — Maximum Allowable Quantities (MAQs) per control area for indoor storage:

Hazard CategoryClassStorage TypeMax Qty (gal)Max Qty (lbs)Notes
Flammable LiquidIAindoor_unprotected30Flash point < 73°F, BP < 100°F
Flammable LiquidIAindoor_cabinet60Approved flammable cabinet
Flammable LiquidIBindoor_unprotected120Flash point < 73°F, BP >= 100°F
Flammable LiquidIBindoor_cabinet120Approved flammable cabinet
Flammable LiquidICindoor_unprotected120Flash point 73–100°F
Flammable LiquidICindoor_cabinet120Approved flammable cabinet
Combustible LiquidIIindoor_unprotected120Flash point 100–140°F
Combustible LiquidIIIAindoor_unprotected330Flash point 140–200°F
Combustible LiquidIIIBindoor_unprotected13,200Flash point >= 200°F
Oxidizer1indoor_unprotected4,000Class 1 oxidizer
Oxidizer2indoor_unprotected250Class 2 oxidizer
Oxidizer3indoor_unprotected10Class 3 oxidizer
Oxidizer4indoor_unprotected1Class 4 oxidizer — any amount

Data source: Values come from IFC Table 5003.1.1(1) (International Fire Code, published by the International Code Council). The code_reference column in every row stores 'IFC Table 5003.1.1(1)'. These values may also align with NFPA 1 (Fire Code) and NFPA 30 (Flammable and Combustible Liquids Code). The jurisdiction column defaults to 'general' but can be overridden for state/city-specific limits.

Seed SQL: docs/features/site_chemical_quantity_aggregation.md (lines 491–512).

2.4 Tier II Thresholds

Unlike fire code thresholds, Tier II thresholds are not stored in a database table. They are hardcoded constants in the background service:

TIER2_HAZARDOUS_THRESHOLD = 10000   # 10,000 lbs — EPCRA Section 312
TIER2_EHS_THRESHOLD = 500 # 500 lbs — for Extremely Hazardous Substances (not yet implemented)

Data source: EPA EPCRA (Emergency Planning and Community Right-to-Know Act), SARA Title III, Section 312. Facilities must file annual Tier II reports for any hazardous chemical stored above the threshold planning quantity (TPQ). The general threshold is 10,000 lbs; Extremely Hazardous Substances (EHS) listed under EPCRA Section 302 have a lower threshold of 500 lbs (or their specific TPQ).


3. Step-by-Step Aggregation Process

Step 1: Load Reference Data

The service loads three reference datasets:

  • _load_unit_conversions() → Dict of unit_code → {type, factor, standard_unit}
  • _load_density_data() → Dict of CAS → density_lbs_per_gal
  • _load_fire_code_thresholds() → Dict of "category:class:storage_type" → {max_gal, max_lbs}

Step 2: Aggregate Inventory by CAS Number

Source query joins:

  • chemiq_inventory (active items at the site)
  • chemiq_company_product_catalog (product details, active products)
  • chemiq_sds_composition (chemical components of each product, via current_sds_id)
  • core_data_site_locations (storage location names)

Key behavior: Each inventory item expands to multiple rows — one per chemical component in its SDS composition. This means a single product with 3 chemical components generates 3 rows.

Quantity calculation per component:

item_total = container_size × quantity (number of containers)

concentration = COALESCE(concentration_exact, concentration_max, concentration_min) / 100
(defaults to 1.0 if no concentration data — i.e., treat as 100%)

If size_unit is a WEIGHT unit:
lbs = item_total × conversion_factor × concentration
gal = lbs ÷ density_lbs_per_gal

If size_unit is a VOLUME unit:
gal = item_total × conversion_factor × concentration
lbs = gal × density_lbs_per_gal

If size_unit is unknown:
lbs = item_total × concentration

All component quantities for the same CAS number are summed.

Container counting: Containers are only counted once per inventory item (not once per component), using a seen_chemical_ids set to deduplicate.

Step 3: Determine Hazard Classification

For each CAS group, the service looks up hazard information from chemiq_sds_hazard_info (the parsed GHS section of the SDS). This provides:

  • hazard_classes (e.g., [\{"hazard_class": "Flammable liquid", "hazard_category": "Category 3"\}])
  • signal_word
  • hazard_statements

is_hazardous = true if any hazard classes are present.

Fire code classification is determined by the _determine_fire_code_class() method which maps GHS hazard classes to IFC categories:

GHS Hazard ClassGHS Category→ IFC Fire Code Category→ IFC Class
Flammable liquidCategory 1Flammable LiquidIA
Flammable liquidCategory 2Flammable LiquidIB
Flammable liquidCategory 3Flammable LiquidIC
Flammable liquidCategory 4Combustible LiquidII
OxidizerCategory 1Oxidizer4
OxidizerCategory 2Oxidizer3
OxidizerCategory 3Oxidizer2
Corrosive(any)Corrosive
Toxic / Acute Toxicity(any)Toxic

Note: The method returns the first match — so if a chemical has both "Flammable liquid Category 3" and "Oxidizer Category 1", only the first (flammable) is used.


4. Chemical Summary Tab — Column Definitions

Each row in the Chemical Summary tab represents one CAS number aggregated across all inventory items at the site.

ColumnSourceCalculation
CAS Numberchemiq_sds_composition.cas_numberGrouped by CAS
Chemical Namechemiq_sds_composition.chemical_nameFirst match from SDS composition
Qty (lbs)AggregatedSum of all lbs for this CAS (see Step 2 formula)
Qty (gal)AggregatedSum of all gal for this CAS (see Step 2 formula)
Containerschemiq_inventory.quantityCount of containers (deduplicated per inventory item)
ProductsAggregatedCount of distinct company_product_id values contributing to this CAS
Hazardouschemiq_sds_hazard_infotrue if any GHS hazard classes are associated with the product's SDS
Tier IICalculatedSee Section 5 below
Fire CodeCalculatedSee Section 6 below

5. Tier II Threshold — How It Is Determined

What is Tier II?

EPA EPCRA Section 312 requires facilities to report chemicals stored above certain thresholds. This is the "Tier II" report filed annually.

Threshold Values (Hardcoded Constants)

TIER2_HAZARDOUS_THRESHOLD = 10,000 lbs   # For most hazardous chemicals
TIER2_EHS_THRESHOLD = 500 lbs # For Extremely Hazardous Substances (not yet implemented)

Determination Logic

IF chemical is_hazardous = true:
tier2_threshold_lbs = 10,000
exceeds_tier2_threshold = (total_quantity_lbs >= 10,000)
ELSE:
tier2_threshold_lbs = NULL
exceeds_tier2_threshold = false

In the UI: A green checkmark means the chemical is below threshold. A red X-circle means it exceeds.

Tier II Report Data (separate endpoint)

The /sites/\{site_id\}/tier2-report endpoint uses daily quantity snapshots (chemiq_site_quantity_history) to calculate:

  • Max daily amount: MAX(quantity_lbs) across all snapshot dates in the year
  • Average daily amount: AVG(quantity_lbs) across all snapshot dates
  • Days on site: COUNT(DISTINCT snapshot_date) where is_present = true
  • Exceeds threshold: max_daily_amount >= 10,000 lbs

6. Fire Code Threshold — How It Is Determined

What is Fire Code Compliance?

The International Fire Code (IFC) sets maximum allowable quantities (MAQs) per control area. Exceeding these requires a fire permit.

Determination Logic (Per Chemical)

1. Determine fire_code_category and fire_code_class from GHS hazard classes
(using the mapping table in Section 3)

2. Look up threshold in chemiq_fire_code_thresholds:
key = "{fire_code_category}:{fire_code_class}:inside_building"

3. Get permit limit:
fire_permit_threshold = threshold.max_gal OR threshold.max_lbs

4. Compare:
IF fire_permit_threshold exists AND total_quantity_gal >= fire_permit_threshold:
exceeds_fire_threshold = true
ELSE:
exceeds_fire_threshold = false

In the UI: A green checkmark means the chemical is within fire code limits. A red X-circle means it exceeds.

Note: The comparison currently uses gallons (total_quantity_gal >= fire_permit_threshold) for liquid categories (flammable/combustible) where thresholds are defined in gallons. For oxidizers (thresholds in lbs), it falls back to max_lbs.


7. Hazard Summary Tab — Column Definitions

Each row in the Hazard Summary tab represents one hazard class group, aggregating all chemicals at the site that share the same fire code classification.

How Rows Are Created

Chemicals are grouped by fire_code_category:fire_code_class. Only chemicals with a fire_code_category (determined from their GHS hazard classes) appear in this table. Non-hazardous chemicals are excluded.

Column-by-Column Breakdown

ColumnSourceCalculation
Hazard Category_determine_fire_code_class()The IFC fire code category derived from GHS class (e.g., "Flammable Liquid", "Oxidizer", "Corrosive", "Toxic")
Hazard Class_determine_fire_code_class()The IFC sub-class (e.g., "IA", "IB", "IC", "II", "2", "3", "4") or "General" if none
Qty (lbs)AggregatedSUM(total_quantity_lbs) for all chemicals in this hazard group
Qty (gal)AggregatedSUM(total_quantity_gal) for all chemicals in this hazard group
ChemicalsAggregatedCOUNT of distinct CAS numbers in this hazard group
Fire Permit Limitchemiq_fire_code_thresholdsThe MAQ from the fire code threshold table for this category:class:inside_building combination. NULL if no threshold exists for this hazard type
% of LimitCalculated(total_qty / fire_permit_limit) × 100. Uses total_quantity_gal if the threshold is defined in gallons, total_quantity_lbs if defined in lbs. NULL if no permit limit exists
ExceedsCalculatedtrue if total_qty >= fire_permit_limit. Green check = within limits, Red X = exceeds

Detailed Calculation for % of Limit and Exceeds

# From _aggregate_by_hazard_class():

threshold_key = f"{fire_code_category}:{fire_code_class}:inside_building"
fire_threshold = fire_thresholds.get(threshold_key, {})
permit_limit = fire_threshold.get("max_gal") or fire_threshold.get("max_lbs")

exceeds_limit = False
percent_of_limit = None

if permit_limit:
# Use gallons if threshold is in gallons, otherwise use lbs
total = total_quantity_gal if fire_threshold.get("max_gal") else total_quantity_lbs

if total >= permit_limit:
exceeds_limit = True

percent_of_limit = (total / permit_limit) * 100

8. Stat Cards on the Page Header

CardValueSource
Total ChemicalsCount of all CAS rowsCOUNT(*) from chemiq_site_chemical_summary
HazardousCount of hazardous CAS rowsSUM(CASE WHEN is_hazardous THEN 1 ELSE 0 END)
Exceeds Tier IICount of chemicals exceeding Tier IISUM(CASE WHEN exceeds_tier2_threshold THEN 1 ELSE 0 END)
Exceeds Fire CodeCount of chemicals exceeding fire codeSUM(CASE WHEN exceeds_fire_threshold THEN 1 ELSE 0 END)

9. Key Files

LayerFilePurpose
Background Servicetellus-ehs-background-service/app/services/quantity_aggregation/service.pyCore aggregation logic, threshold calculations
API Endpointstellus-ehs-hazcom-service/app/api/v1/chemiq/site_quantities.pyREST endpoints for chemical/hazard summaries
DB Modelstellus-ehs-hazcom-service/app/db/models/site_quantity.pySQLAlchemy models for all 6 tables
Pydantic Schemastellus-ehs-hazcom-service/app/schemas/chemiq/site_quantity.pyRequest/response validation
Frontend Pagetellus-ehs-hazcom-ui/src/pages/chemiq/site-quantities/index.tsxReact page with tabs
Frontend Typestellus-ehs-hazcom-ui/src/types/siteQuantity.tsTypeScript type definitions
API Clienttellus-ehs-hazcom-ui/src/services/api/chemiq.api.tsFrontend API call functions

10. Database Tables

TablePurpose
chemiq_site_chemical_summaryPre-computed per-CAS aggregation per site (Chemical Summary tab)
chemiq_site_hazard_summaryPer-hazard-class aggregation per site (Hazard Summary tab)
chemiq_site_quantity_historyDaily quantity snapshots for Tier II max/avg calculations
chemiq_unit_conversionsReference: unit conversion factors
chemiq_product_densityReference: chemical densities for weight/volume conversion
chemiq_fire_code_thresholdsReference: IFC maximum allowable quantities

11. Current Limitations

  1. EHS threshold (500 lbs) is defined as a constant but not yet implemented in the determination logic — only the 10,000 lbs general hazardous threshold is used.
  2. Fire code classification uses the first matching GHS class only. A chemical with multiple hazard classes (e.g., flammable AND oxidizer) only gets classified under the first match.
  3. Storage type defaults to inside_building when looking up fire code thresholds. Cabinet storage (indoor_cabinet) thresholds exist but are not dynamically selected based on actual storage type.
  4. Concentration fallback: If no concentration data exists in SDS composition (concentration_exact, concentration_max, concentration_min all NULL), the chemical is treated as 100% concentration.
  5. Daily snapshots must be created separately (via create_daily_snapshot()) to populate Tier II report historical data. Without snapshots, the Tier II report endpoint returns empty results.