Assisted point mapping
Getting BAS tags mapped to CAMBER's vendor-neutral Role vocabulary is the gate on everything else —
a rule can't run on a point it can't find. camber.model.mapping.MappingProvider resolves a tag by
alias or regex, and camber.mapping_confidence scores how sure that resolution is. camber.mapping_assist
adds the missing piece: when a tag doesn't resolve, propose the most likely roles.
flowchart LR
token["BAS point token"]
feat["FeatureSuggester"]
ml["MLSuggester (ml)"]
llm["LLMSuggester"]
score["mapping_confidence re-score"]
sugg["RoleSuggestion (ranked)"]
review["review_unmapped list"]
operator["operator confirms + edits mapping"]
token --> feat
token --> ml
token --> llm
feat --> score
ml --> score
llm --> score
score --> sugg
sugg --> review
review -- advisory --> operator
Any suggester proposes; the deterministic mapping_confidence re-score arbitrates; the operator applies.
It is advisory only, by construction — it returns a ranked, human-confirmed review list and
never mutates a MappingProvider. A confirmed suggestion is applied by the operator editing the
mapping spec (MappingProvider.from_dict), the same boundary camber.aso keeps toward the BAS.
One interface, three suggesters
Every suggester implements suggest(token, *, series=None, unit=None, k=3) -> list[RoleSuggestion]:
| Suggester | Dependency | Signal |
|---|---|---|
FeatureSuggester |
numpy / stdlib (always available) | tag string + unit + physical-range fit; with use_timeseries=True also the data's behaviour (0.96) |
MLSuggester |
scikit-learn ([ml] extra, lazy) |
learned char-n-gram classifier |
LLMSuggester |
an injected LLM callable (the agent seam) | model proposal, deterministically re-scored |
A RoleSuggestion is token, role (always a valid Role value), confidence (0..1), basis
(initials/ngram/edit_distance/unit/range_fit/timeseries/combined/ml/llm), rationale, and as_dict().
Baseline — FeatureSuggester
Dependency-light and always on. It scores every Role from three signals:
- Whole-token match — the tag is split into words on
_/-/., camelCase humps and acronym boundaries (ReHeatVlvPos_1→ re heat vlv pos,HWVlvPos→ hw vlv pos; a split that tore an abbreviation apart, likeCh+W, is re-joined). Each word is rewritten to the concept it names through a BAS-abbreviation table (oa/outside→ outdoor,da/sa/discharge→ supply,vlv→ valve,dmpr→ damper,hw→ hot water + heat,chw→ chilled water + cool,zone/room→ space,sat/mat/oat→ the compound, …), and so is each role slug. The score is0.9 × recall × (0.5 + 0.5 × precision): recall is the share of the role's concepts the tag names, precision the share of the tag's words the role explains (location words likezoneweigh half, unknown words dilute it,pos/cmd/air/equipment prefixes are noise). A word that equals a role's initials (DSS→duct_static_sp) names all of it; a misspelled long word (Temprature) still matches (basisedit_distance). Initials only ever match a whole word — earlier releases matched them as substrings of the unsplit name, soOaTempbecameoa_airflow,ReHeatVlvPosevap_approach_tempandSATsat_reset_requests; on a published 16-point AHU list the name-only top suggestion was right for 3 points and is now right for 16. An outdoor/return/exhaust/relief/mixed damper is not suggested as the terminaldamperrole. 0.100 (#102) extends the vocabulary and adds context rules; see Vocabulary and context below. - Unit compatibility — a
ROLE_UNITtable (degF/degC → temp,%→ valve/damper/speed, cfm → airflow, kW → power, gpm → flow, inH2O → duct static, ppm → CO₂). A compatible unit gives a small bump (and on its own a weak ≤ 0.3 suggestion); a known-incompatible unit strongly demotes the role. - Physical-range fit — if a
seriesis given,sensorhealth.range_violation_frac(series, role)demotes any role whose physical bounds the data violates (a 500 °F "supply air temp" falls away). The bounds are in °F; a series declared in °C (or K) is converted first — previously every correctly named °C temperature failed its bounds and landed onwetbulb_temp. The same unit-aware check gatesMLSuggesterandLLMSuggester.
from camber.mapping_assist import suggest_roles
for s in suggest_roles("AH1_SAT", unit="degF", series=sat_series, k=3):
print(s.role, round(s.confidence, 2), s.rationale)
# supply_air_temp 0.98 'AH1_SAT' matches the initials of supply_air_temp; unit 'degf' fits ...
Vocabulary and context (0.100)
The 0.96 evaluation on real names showed that most misses were vocabulary gaps. 0.100 (#102) fills them from public conventions: Project Haystack tag names, Brick class names and common BAS point-naming practice. Nothing in it is dataset-specific.
- New abbreviations.
AF(airflow);CMHandLPS(flow units used as words).WH,kWh,POW,MELsand lighting (LTG) for power.STA,EnableandSS(start/stop) for status.PMandPMPfor pump;CHWP,HWPandCWPfor the loop pumps.CT(cooling tower),SAFandSF(supply fan),BOI(boiler).SW,RW,SWT,RWT,LWTandEWTfor supply, return, leaving and entering water.HWL,CHWLandCDWLfor the hot-water, chilled-water and condenser-water loops.HValve/CValvefor heating and cooling valves;HC,CCandLATfor coil leaving air.- presence, occupant,
PIRand motion for occupancy. - weather,
WXand meteo for an outdoor (weather-station) point; dry-bulb, wet-bulb and dew point name temperatures of their own. - Particulate matter (
PM2.5,PM10) is kept whole, so it is never read as a pump. - Run-together words (
OADMPR,RMCLGSPT,HWL_DPSPT) are split into known abbreviations. Only an unknown word of five letters or more is tried. Every piece must be known, the split with the fewest pieces wins, and a trailingSPTin such a word is a setpoint. A real word that does not split cleanly (lecture) is left alone. - Vowel-dropped abbreviations (
Sply→ supply) are read when the word has four letters or more and at most one vowel, starts with the long word's first letter, keeps its letters in order, and fits only one meaning. A real word such ascorekeeps its vowels and is not matched. - Water, not air. A tag that names water (
water,SW,GPM, a loop,HW/CHW/CW, or a chiller, boiler or tower) does not get an air-side role (airflow,supply_air_temp, a zone setpoint...) or a refrigerant temperature. Such a role is multiplied byWATER_AIR_PENALTY(0.4). Equipment words add the loop to what the tag covers (CHL_SW_TEMP→chw_supply_temp) but never suggest a role on their own. - Unlocated temperatures. A temperature that names no location and no kind of temperature
(
air_temperature,Temp) is read as a space temperature, the commonest in a building. Before 0.100 it became outdoor air only because that role came first. Every other temperature role is multiplied byUNLOCATED_TEMP_PENALTY(0.6). The same factor separates kinds of temperature: a tag that says wet-bulb is not a dry-bulboat. A zone setpoint (cool_sp,heat_sp) accounts for an unlocated temperature word, sotemp_setpoint→cool_sp(thenheat_sp), notsupply_air_temp_sp. - UUIDs name nothing. A UUID's hex groups (
cc45,af12) are not read as abbreviations.
This changes the default output. On a 6,546-case corpus (1,091 point names from the shipped
catalog mappings, the evaluation datasets, BTS's Brick classes and a list of generic BAS names,
each with no unit and with five units), 2,076 cases changed and the top-1 role changed in 854.
The 0.95 golden cases are unchanged. The top-1 changes are names that had no suggestion before
(AF_VAV_7, SF_Enable, RMCLGSPT), unlocated temperatures moving from oat to space_temp,
setpoints moving from supply_air_temp_sp to cool_sp, water points leaving air-side roles,
and pump and tower-fan speeds leaving supply_fan_speed.
Time-series evidence — use_timeseries=True (0.96)
A name is the best evidence when it is informative, and none at all when the export is anonymised
(UUIDs, AI_0417, numeric object ids): the name-only suggester above suggests nothing for such a
point. FeatureSuggester(use_timeseries=True) also reads what the data says
(camber.mapping_timeseries, numpy and pandas only):
- Profile (
profile_series(series, oat=None)→SeriesProfile): value quantiles and range; cadence and the change-of-value pattern (share of samples that change, run lengths, how much of the movement comes in steps); binary (0/1) and two-level values, integer values, the share of samples at the series' own extremes (a valve closed, a damper at its minimum position); the daily and weekly periodicity; and, given an outdoor-air series of the site, the correlation of daily means (a load or a reset responds to the weather) and of hourly means (a sensor in the outdoor air follows it hour by hour). - Role templates (
ROLE_TEMPLATES,template_scores): 45 hand-written, physically motivated templates -- the quantity, the typical level and spread in CAMBER's units (a series is read in every plausible unit when none is declared: °C or °F, Pa or inH2O, L/s or cfm...), the kind (sensor, setpoint, status, command, stage), the expected response to the weather and how common the role is (a building has hundreds of zone points and a few plant points). A template whose typical band spans decades (an airflow) is weighed down: it fits almost anything. No training is needed. - Fitted model (optional,
ProfileModel): a numpy Gaussian class model over the profile features, fitted on labelled points of other buildings; pass it asmodel=to use it instead of the templates. - Blend with the name (
blend): the data's weight falls from 0.8 when the name says nothing to 0.1 once the best lexical score reaches 0.6 (INFORMATIVE_NAME), so an informative name still dominates and the data only breaks its ties.
from camber.mapping_assist import FeatureSuggester, suggest_roles
fs = FeatureSuggester(use_timeseries=True, oat=site_oat_series)
fs.suggest("3dfa2bab_f8f2_485b", series=point_series)
# [RoleSuggestion(role='space_temp', basis='timeseries',
# rationale='sensor data at the level of space_temp (read as degC)'), ...]
suggest_roles("3dfa2bab_f8f2_485b", series=point_series) # 0.101: the same path by default
suggest_roles("3dfa2bab_f8f2_485b", series=point_series, use_timeseries=False) # the opt-out
The default, from 0.101 (#107). When a series is passed and no suggester is given,
suggest_roles, review_unmapped and camber.interop.bacnet.review_bacnet use the time-series
path. use_timeseries=False opts out and gives the 0.100 output, byte for byte: the name, the
unit and the physical-range check. Without a series the default is unchanged. The functions
pass no outdoor-air series, so the weather features are not measured; build
FeatureSuggester(use_timeseries=True, oat=...) and pass it as suggester= to use them. The
FeatureSuggester class itself stays name-only unless it is built with use_timeseries=True.
Until 0.100 the time-series path was opt-in only.
Range check. On the time-series path, the physical-range gate runs in the declared unit. Until 0.100 it ran only when a unit was declared. From 0.101 (#106), a
point with no unit is range-checked in every plausible unit of the role, the same readings the
templates try (°C or °F, Pa or inH2O...), and the best reading counts. A 22 °C room is no
longer out of range for being read as °F, and a sentinel or dead channel (-999) that fits no
unit is demoted as it is on the default path.
Range check on dirty series (0.102, #110). Two changes stop the unitless check from overturning a strong name on a dirty series:
- Exact zeros where the role cannot read 0 are dropouts. A 0 is left out of the check when no plausible unit puts it inside the role's bounds (a 0 °C room is 32 °F, below a space temperature's floor; no CO2 reads 0 ppm). An all-zero series then gives no range verdict. Where 0 is a real reading (a closed damper, a stopped fan's airflow), zeros still count.
- A strong name keeps at least 75 % of its score. When the role is the name's best lexical
match and scores at least 0.6 (
INFORMATIVE_NAME, where the data only breaks ties), the check multiplies its score by no less thanSTRONG_NAME_RANGE_FLOOR(0.75). Negative or sign-flipped flows, sentinel codes and mixed scales then cost a strong name some score, but they no longer hand top-1 to a role with no physical bounds, such as an airflow setpoint. A series with no reading inside the bounds in any plausible unit (a-999dead channel) is still demoted in full.
Both act only on the time-series path, and only without a declared unit. A declared unit is trusted as before.
Setpoint guard (0.101, #106). An occupant-adjusted or reset zone setpoint moves like the room
it controls, so the data alone read temp_setpoint as space_temp. When the name says setpoint
(setpoint, SP, STPT, or a run-together RMCLGSPT), the data now ranks only the setpoint
roles. It still chooses a cooling, heating or supply-air setpoint by the level, but it cannot
turn a setpoint into a sensor. SPT alone is a space temperature, so it does not trigger the
guard.
Weather-station guard (0.100, #102). A weather-station point is often a different sensor from
the site's outdoor reference, so the hour-by-hour weather features did not make it look
"outdoor" enough. The data alone then read an outdoor temperature as a wet-bulb and an outdoor
humidity as a supply-air humidity. When the name places a point outdoors, the data now ranks
only the outdoor roles. For a weather station (weather, WX, meteo, or the Synoptic /
MesoWest _set_N suffix of air_temp_set_1), it ranks only the weather quantities (oat,
outdoor_rh, outdoor_co2), not an outdoor damper or airflow. A wet-bulb needs the name to say
so, because outdoor dry- and wet-bulb temperatures behave alike.
Evaluation
Three evaluations, none of them gated benchmarks. Scripts and details are in examples/suggester_eval.
- Real point names on open catalog datasets: the honest real-world figures.
- BTS with the names hidden: what the data alone recovers.
- Synthetic vendor-style names: tolerance to naming conventions. These figures are synthetic and never mixed with the real ones.
Real point names (open catalog datasets)
real_names.py scores every point of each dataset below by its published BMS name. The
tables in this section are the 0.96 baseline, before the 0.100 vocabulary; the 0.100 figures are
in 0.100: before and after.
- Labels. The ground truth is the dataset's catalog mapping: the role CAMBER assigned to each
published point name when the dataset was catalogued. CAMBER hand-curated these mappings from
the publisher's documentation and the data. For the
lbnl-b59rooftop units (16 points), the label is the publisher's own Brick class, with CAMBER's overrides. - Which points are scored. Only mapped points are scored. Columns CAMBER derives at ingest are skipped, because they are not published names. A point repeated across scenario or fault runs is scored once.
- Leakage. The 0.9x name tokenizer was written against the
irish-ahupoint list, whose 16 names are a unit test. Its noise words (fbk,tn) and a range-check test come fromlbnl-b59names. The lexical figures for those two datasets are therefore in-sample, and the "excluding in-sample names" rows are the out-of-sample reference. - Name variety.
lbnl-b59contributes 284 points but only 24 name patterns (for example,zone_<n>_tempappears 51 times), so it dominates the pooled real figure. The mean over datasets counts every dataset once.
| dataset | points (distinct names) | name only top-1 / top-3 % | data only top-1 / top-3 % | name + data top-1 / top-3 % |
|---|---|---|---|---|
lbnl-b59 (in-sample names) |
284 (284) | 96.1 / 96.5 | 37.0 / 52.5 | 95.8 / 96.8 |
irish-ahu (in-sample names) |
9 (9) | 77.8 / 77.8 | 33.3 / 66.7 | 77.8 / 77.8 |
nuig-ahu101 |
13 (13) | 76.9 / 84.6 | 23.1 / 38.5 | 61.5 / 92.3 |
robod |
46 (11) | 60.9 / 60.9 | 45.7 / 56.5 | 60.9 / 78.3 |
b4b-windesheim |
13 (6) | 61.5 / 61.5 | 76.9 / 92.3 | 84.6 / 84.6 |
sdu-ou44 |
9 (9) | 66.7 / 66.7 | 66.7 / 66.7 | 66.7 / 66.7 |
ornl-frp-ops |
48 (48) | 33.3 / 33.3 | 33.3 / 56.2 | 45.8 / 60.4 |
| real buildings, pooled | 422 | 82.5 / 82.9 | 38.9 / 54.7 | 83.9 / 89.1 |
| real buildings, mean over 7 datasets | 67.6 | 45.1 | 70.4 | |
| real, excluding in-sample names, pooled | 129 | 52.7 / 53.5 | 43.4 / 58.9 | 58.1 / 72.9 |
| real, excluding in-sample names, mean over 5 datasets | 59.9 | 49.1 | 63.9 |
The LBNL simulated FDD sets are reported apart, because their names are systematic and
simulation-style (SA_TEMP, CHL_SW_TEMP_1, HWL_DPSPT). This table is re-run with the 0.99.1
vocabulary on the 0.100 catalog, which has gained points since 0.96: lbnl-chiller has 14 points,
lbnl-fpu 20, and lbnl-ddahu 11, because its cold-deck setpoint CSA_TEMPSPT is mapped from
0.100 (#98).
| dataset (simulated) | points | name only top-1 / top-3 % | data only top-1 / top-3 % | name + data top-1 / top-3 % |
|---|---|---|---|---|
lbnl-sdahu |
12 | 58.3 / 66.7 | 50.0 / 58.3 | 75.0 / 83.3 |
lbnl-fcu |
12 | 50.0 / 58.3 | 25.0 / 33.3 | 66.7 / 75.0 |
lbnl-ddahu |
11 | 54.5 / 72.7 | 27.3 / 36.4 | 81.8 / 81.8 |
lbnl-fpu |
20 | 60.0 / 70.0 | 30.0 / 55.0 | 60.0 / 70.0 |
lbnl-chiller |
14 | 7.1 / 35.7 | 0.0 / 7.1 | 7.1 / 28.6 |
lbnl-boiler |
9 | 33.3 / 44.4 | 0.0 / 11.1 | 33.3 / 66.7 |
| simulated, pooled | 78 | 44.9 / 59.0 | 23.1 / 35.9 | 53.8 / 66.7 |
Where the data helps and hurts the name. Adding the data (use_timeseries=True) changed the
top-1 result of 16 real-building points: it helped 11 and hurt 5. On the simulated sets it
helped 7 and hurt none.
- Helped:
- Room temperatures whose names never say "zone" or "room" (
..._Lecture_Theatre_3_Avg_Temp,bms_temp_in__degCin three rooms). The name alone reads these as outdoor air. - Six of the ten
ornl-frp-opsVAV discharge temperatures (T_VAV_103), which the name alone cannot place at all. lbnl-b59'shp_hws_temp, which the name alone reads as a supply-air temperature.- On the simulated sets, cooling valves the name read as heating valves (
CHWC_VLV_DM), unplaced FCU valves, a supply-air setpoint (SA_TEMPSPT), a cold-deck setpoint (CSA_TEMPSPT) and a cold-deck temperature (CSA_TEMP). - Hurt:
- Weather-station points, twice: a correctly named outdoor temperature became
wetbulb_temp, and an outdoor humidity becamesupply_air_humidity(nuig-ahu101, andlbnl-b59'sair_temp_set_1/relative_humidity_set_1). The site's outdoor reference used for the weather features is a different sensor, so the point does not look "outdoor" enough. nuig-ahu101's main heating valve became a cooling valve.
Common confusions.
- Name only:
- Unknown abbreviations give no suggestion at all:
AF_(airflow) andWH_(power) inornl-frp-ops,PM_STA(pump status),occupant_presence,SAF_Enable. - A temperature without a location defaults to outdoor air. Examples:
air_temperature, and the simulated plant'sCHL_SW_TEMP/HWL_SW_TEMP(SW/RWfor supply and return water are not in the vocabulary). - A setpoint without "cool" or "heat" becomes
supply_air_temp_sp(temp_setpoint). - Water flows become
airflow, and pump or tower-fan speeds becomesupply_fan_speed. - A generic valve or VAV signal (
vav_room_1,bms_valve_frac) goes to the wrong actuator. - Data only:
- Fan speeds and valves read as dampers or filter pressure drops.
- Zone cooling setpoints read as duct-static setpoints, and heating setpoints as supply-air setpoints.
- Airflows read as filter pressure drops, and return-air temperatures as zone temperatures.
Reading it.
- Real naming is much harder than the Brick-class upper bound. Out of sample, the name alone places 53 % of real points first, not 93 %.
- The data is a modest net gain on real names: pooled top-1 rises from 52.7 to 58.1 % and top-3 from 53.5 to 72.9 %. The largest gain is on names the tokenizer cannot read at all.
- Where a name is informative, the data rarely matters. On
lbnl-b59top-1 moves by -0.3 points. - The data costs weather-station points. That is the one systematic loss.
- Many misses are vocabulary gaps, not data problems (
AF,WH,SW/RW,STA, "presence"). 0.100 extends the abbreviation table (see below); these figures stay the baseline.
Attribution (as each catalog entry requires; camber datasets info <id> gives the full
record):
lbnl-b59: Luo et al. (2022), Scientific Data 9:156, doi:10.1038/s41597-022-01257-x, CC BY 4.0.irish-ahu: Ahern, O'Sullivan & Bruton (2023), Mendeley Data doi:10.17632/8x62ntvrg7.2 and Data in Brief 48:109208, CC BY 4.0.nuig-ahu101: Messervey et al. (2019), HIT2GAP, Zenodo doi:10.5281/zenodo.3406555, CDLA Permissive 1.0.robod: Tekler et al. (2022), Building Simulation 15(12):2127-2137, doi:10.1007/s12273-022-0925-9, CC BY 4.0.b4b-windesheim: ter Hofte, van Ravenzwaaij & Nijboer (2023), Brains4Buildings2022 dataset, CC BY 4.0.sdu-ou44: Schwee et al. (2019), Scientific Data 6:287, doi:10.1038/s41597-019-0274-4, CC0.ornl-frp-ops: Yoon, Jung, Im & Gehl (2022), Scientific Data 9:775, doi:10.1038/s41597-022-01858-6, CC BY 4.0.lbnl-*simulated sets: Granderson et al. (2022), LBNL Fault Detection and Diagnostics Datasets, doi:10.25984/1881324, CC BY 4.0.
0.100: before and after (#102)
The vocabulary additions and the weather-station guard, re-run on the same scripts and inputs. "Before" is 0.99.1 re-run today. Its figures match the baseline tables above; the simulated table is already on the 0.100 catalog (78 points).
Read the real-name rows as in-sample. The 0.100 vocabulary was chosen from the 0.96 misses
on these names, so the "excluding in-sample names" row is no longer out of sample.
catalog_names.py is the out-of-sample name check: it scores the published names of the
catalog mappings that real_names.py does not use (223 points, 11 mapping files, name only,
no download).
| evaluation | before top-1 / top-3 % | after top-1 / top-3 % |
|---|---|---|
| real names, pooled (422), name only | 82.5 / 82.9 | 94.3 / 95.0 |
| real names, pooled (422), name + data | 83.9 / 89.1 | 95.3 / 96.9 |
| real, "excluding in-sample names" (129), name only | 52.7 / 53.5 | 86.0 / 88.4 |
| real, "excluding in-sample names" (129), name + data | 58.1 / 72.9 | 89.1 / 93.8 |
| simulated LBNL sets (78), name only | 44.9 / 59.0 | 71.8 / 85.9 |
| simulated LBNL sets (78), name + data | 53.8 / 66.7 | 78.2 / 85.9 |
| held-out catalog names (223), name only (out of sample) | 72.2 / 75.3 | 83.0 / 85.2 |
| BTS anonymised (903), name + data, templates | 48.0 / 65.2 | 48.0 / 65.2 |
| BTS Brick-class labels as names (upper bound), name only | 93.0 / 97.2 | 93.2 / 97.2 |
| BTS Brick-class labels as names (upper bound), name + data | 95.2 / 99.9 | 95.5 / 99.9 |
The data-only rows do not change (38.9 / 54.7 real, 23.1 / 35.9 simulated): the vocabulary is name-side.
Synthetic vendor-style names (messy_names.py; synthetic, not real-world naming):
| naming style (synthetic) | name only top-1 before → after % | name + data top-1 before → after % |
|---|---|---|
AHU1_SAT |
25.1 → 64.6 | 51.8 → 74.0 |
VAV-2-14 DA-T |
57.4 → 64.7 | 74.0 → 73.9 |
B2.L3.FCU07.RmTmp |
67.9 → 79.4 | 77.6 → 84.4 |
ahu_03_supply_temp |
87.9 → 93.9 | 90.9 → 94.1 |
201-AHU3:SA-TMP |
56.7 → 63.3 | 74.0 → 74.0 |
- Out of sample, the name alone gains 11 points of top-1 (72.2 → 83.0 % on the held-out
catalog names). Most of the gain is run-together setpoints (
CLGSP_102,HTGSP_102), wet-bulb temperatures and occupant counts. - One held-out top-3 hit was lost: a NIST heat-pump temperature (
1500_ODLiqSV_TempF) now reads as a space temperature instead of outdoor air. Both are wrong. - The remaining held-out misses are refrigerant-side names (
P-LT-SUC,CompSuct_Suph_F,ch1_sh_rtd). They are left for a later vocabulary pass, so this set stays out of sample. - The weather-station losses are gone. The data no longer hurts any weather-station point:
air_temp_set_1,relative_humidity_set_1and the NUIG weather temperature and humidity keep their outdoor roles. The guard alone does not change a top-1 on these sets, because the extended names already place them outdoors. It keeps the runners-up on outdoor roles, and it decides the case where the name says only "weather" (tested). - Where the data still changes top-1 on real names, it helps 6 points (the
ornl-frp-opsVAV discharge temperaturesT_VAV_*, whose names give no location) and hurts 2. Before, it helped 11 and hurt 5. The 2 losses are one setpoint namedtemp_setpointin tworobodrooms: the name now sayscool_sp, but the data, which looks like a room temperature, carries it tospace_temp. The simulated sets: helped 5, hurt 0 (before: helped 7, hurt 0). - On
dash-spacenames the data's top-1 moves by -0.1 point. Every other row rises or holds.
BTS: anonymised names
Evaluated on BTS (Prabowo et al., NeurIPS 2024 Datasets and Benchmarks,
doi:10.48550/arXiv.2406.08990; CC BY 4.0; catalog id bts). BTS covers three real Australian
buildings; 903 points have data and a Brick class that maps cleanly to a CAMBER role (bts.py).
- Anonymised rows use the published UUID. They are the honest BTS figures.
- Brick-class rows use the Brick class text as the name. BTS publishes no BMS point names, and that text is effectively the label, so these rows are Brick-class labels used as names: an upper bound, not real-world naming. They show only whether the data spoils a good name. For real names, see the section above.
Upper-bound rows are marked (Brick-class labels used as names, not real-world naming); the anonymised rows are the honest BTS figures.
| method | names | top-1 % | top-3 % | macro top-1 % | site A | site B | site C |
|---|---|---|---|---|---|---|---|
| lexical (0.95 default) | anonymised | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| time series, templates | none | 48.0 | 65.2 | 22.6 | 61.5 | 71.9 | 33.8 |
| time series, fitted (leave one building out) | none | 38.4 | 58.8 | 11.1 | 58.9 | 9.4 | 21.8 |
| combined, templates | anonymised | 48.0 | 65.2 | 22.6 | 61.5 | 71.9 | 33.8 |
| combined, fitted | anonymised | 38.4 | 58.8 | 11.1 | 58.9 | 9.4 | 21.8 |
| lexical (0.95 default) | Brick-class labels used as names (upper bound, not real-world naming) | 93.0 | 97.2 | 81.7 | 92.3 | 84.4 | 94.3 |
| combined, templates | Brick-class labels used as names (upper bound, not real-world naming) | 95.2 | 99.9 | 85.2 | 96.6 | 84.4 | 94.7 |
| combined, fitted | Brick-class labels used as names (upper bound, not real-world naming) | 95.1 | 97.2 | 85.0 | 96.4 | 84.4 | 94.7 |
Site columns are top-1 on each held-out building (A 416 points, B 32, C 455). Macro top-1 averages the 27 roles equally; 294 of the 903 points are zone temperatures.
What it shows, and what it does not:
- Anonymised names:
- The name-only suggester has nothing to go on (0 %). The data alone puts the right role first for 48 % of the points and in the top 3 for 65 %.
- It is strong where the physics is distinctive: zone temperatures (92 % top-1), heating valves (100 %), filter pressure drops (91 %), outdoor air (75 %) and CO2 (56 %).
- It is weak where roles share a level and a behaviour. Discharge-air temperatures read as zone temperatures (58 of 116), supply-air temperature setpoints as zone cooling setpoints (40 of 88), and duct static pressures and airflows as filter pressure drops.
- Chilled- and hot-water temperatures, humidities and pump statuses are rarely placed first.
- Brick-class labels as names (upper bound): adding the data moves top-1 from 93.0 to 95.2 % and top-3 from 97.2 to 99.9 %. No point that the name placed first lost its place; the gains are pump statuses the name alone gave to the boiler. On real names the gain and the losses are different (see above).
- The fitted model does not beat the templates across buildings.
- Trained on two buildings and tested on the third, the numpy model reaches 38 % top-1. A random forest on the same features, tried in a scratch run, reached 32-34 %.
- Each building has its own units, sequences and sensor quality, and three buildings are too few to learn that variety.
- No
[ml]backend is added for this reason. - Caveats:
-
The templates were adjusted while looking at these results, with five physical changes:
- fan-gated pressures judged on their 95th percentile;
- a preference for typical temperature levels;
- the weight on narrow bands;
- the prevalence priors;
- the plateau/movement signature of commands.
The template rows are therefore optimistic for BTS, and the fitted rows are the out-of-sample reference. - Site C's data is the dirtiest of the three (zero dropouts, sentinels, negative airflows; see the entry's data issues) and scores lowest. - BTS units are undocumented, so no unit was passed to the suggester.
Synthetic vendor-style names (BTS points)
messy_names.py turns each of the 903 BTS points' Brick classes into a vendor-style name, using
a seeded generator in five styles. Each style uses abbreviations, an equipment prefix and
number, and separators. The generator's abbreviation tables were written once and not tuned to
the scores. These names are synthetic, not real-world naming.
| naming style (synthetic) | points | name only top-1 / top-3 % | name + data top-1 / top-3 % |
|---|---|---|---|
AHU1_SAT (upper-case, run together) |
903 | 25.1 / 27.0 | 51.8 / 68.4 |
VAV-2-14 DA-T (dashes and a space) |
903 | 57.4 / 69.9 | 74.0 / 84.7 |
B2.L3.FCU07.RmTmp (dotted, camelCase) |
903 | 67.9 / 76.2 | 77.6 / 87.5 |
ahu_03_supply_temp (snake case, long words) |
903 | 87.9 / 97.5 | 90.9 / 98.3 |
201-AHU3:SA-TMP (object id prefix) |
903 | 56.7 / 69.7 | 74.0 / 84.6 |
- Run-together upper-case names (
SUPTMPSTPT) defeat the tokenizer: the name alone places a quarter of them. Single-letter forms (Tfor temperature) are also not recognised. - On these names the data adds 3 to 27 points of top-1, the most where the name is least readable.
- Long-word names come close to the Brick-class upper bound.
Recommendation (0.100; approved and made in 0.101, #107). Switch the time-series path on
by default in suggest_roles and review_unmapped when a series is passed and no suggester is
given, with a way to opt out. The 0.96 objection, that the data costs weather-station points, is
answered by the 0.100 guard. With the extended vocabulary, the data adds top-1 on every real
and simulated pool (real 94.3 → 95.3 %, simulated 71.8 → 78.2 %) and on all five synthetic
styles. It is what places an anonymised point at all (BTS: 0 → 48 %). The costs:
- It changes those callers' output, including the BACnet review path, whenever they pass series.
- One residual loss: a setpoint named only
temp_setpointis read as the room temperature its data resembles. Fixed in 0.101 (#106): the setpoint guard. - Without a declared unit, the time-series path skips the physical-range gate and relies on the templates instead. Fixed in 0.101 (#106): it checks every plausible unit.
Without a series the two paths agree, so callers that pass no data are unaffected. None of these numbers is a gated benchmark.
0.101: held-out names with their data, and the two fixes (#106)
This is step 1 of the recommendation above: measure, and fix the two known costs. Step 2, the default switch, follows below (#107).
catalog_names.py --data scores the held-out catalog names (out of sample for the name
vocabulary) with their series from the catalog data. Each point is read as real_names.py
reads it: 15-minute means from the first fault-free run, with no unit passed. The held-out lab
and test datasets publish runs of 18 to 24 hours, so a series needs half a day of data here, not
two days. Three suggesters are compared:
- name only:
FeatureSuggester()with no series; - name + range check: the name-only suggester with the series passed, the 0.100 default
(from 0.101 the
use_timeseries=Falseopt-out). It adds the physical-range gate, read in °F when no unit is declared; - name + data:
FeatureSuggester(use_timeseries=True)with the series.
Of the 223 held-out names, 89 have a usable series. rbc-g36-ahu (83 names) is research-only and
is not read. The nist-heatpump-fdd runs (15 names) are a few hours long. Of the other 36, 33
are columns CAMBER derives at ingest: the ornl-frp-vav per-room setpoint and occupancy copies
(CLGSP_102...), and fan- and compressor-on flags. Two medium-temperature names share their
series with the low-temperature mapping and are scored there. finnish-dcv's supply-air CO2
has no series in the runs read.
| mapping file | points (with data) | name only top-1 / top-3 % | name + range check top-1 / top-3 % | name + data, 0.100 top-1 / top-3 % | name + data, 0.101 top-1 / top-3 % |
|---|---|---|---|---|---|
cofactor_drammen_heat |
1 (1) | 0.0 / 0.0 | 0.0 / 0.0 | 100.0 / 100.0 | 100.0 / 100.0 |
cofactor_drammen_power |
1 (1) | 0.0 / 0.0 | 0.0 / 0.0 | 100.0 / 100.0 | 100.0 / 100.0 |
finnish_dcv |
5 (4) | 75.0 / 75.0 | 50.0 / 75.0 | 75.0 / 100.0 | 75.0 / 100.0 |
nist_ibal |
10 (10) | 10.0 / 20.0 | 20.0 / 20.0 | 10.0 / 20.0 | 20.0 / 20.0 |
ornl_frp_vav |
92 (61) | 98.4 / 100.0 | 62.3 / 62.3 | 98.4 / 100.0 | 98.4 / 100.0 |
ornl_supermarket_lt |
7 (6) | 33.3 / 33.3 | 0.0 / 0.0 | 33.3 / 33.3 | 0.0 / 0.0 |
ornl_supermarket_mt |
7 (4) | 50.0 / 75.0 | 50.0 / 50.0 | 0.0 / 50.0 | 0.0 / 75.0 |
valladolid_meter |
1 (1) | 0.0 / 0.0 | 0.0 / 0.0 | 0.0 / 0.0 | 0.0 / 0.0 |
valladolid_weather |
1 (1) | 0.0 / 0.0 | 0.0 / 0.0 | 0.0 / 0.0 | 0.0 / 0.0 |
| pooled, with data | 89 | 76.4 / 79.8 | 49.4 / 50.6 | 76.4 / 82.0 | 75.3 / 80.9 |
| pooled, all held-out names | 223 | 83.0 / 85.2 | 72.2 / 73.5 | 83.0 / 86.1 | 82.5 / 85.7 |
Without a series the three suggesters agree, so a name with no data counts the same for each
in the all-names row. cofactor-drammen's Tout is one series under two mapping files.
Where the data changes the name's top-1 (0.101):
- Helped (3):
cofactor-drammen'sTout, twice. The name says nothing, and the data reads outdoor air.nist-ibal'sch1_p_dis. The name alone reads a supply-air temperature, and a pressure of about 220 psi is out of that role's range (the 0.101 range check without a unit).- Hurt (4), all refrigerated-display-case air temperatures in
ornl-supermarket-fdd. The catalog labels them with the air-handler rolessupply_air_tempandreturn_air_temp. - The medium-temperature case's supply and return air (
T-MTCase-Sup,T-MTCase-Ret, 32-45 °F) read as humidities at a percent-like level. This was a loss before 0.101 too. - The low-temperature case's supply and return air (
T-LTcase-Sup,T-LTcase-Ret, about -3 °F) are below the supply- and return-air physical bounds in any unit. The 0.101 range check now demotes them, as the default path already does when a series is passed.
What the two fixes change on every evaluation pool (re-run on the same inputs; "before" is 0.100):
| evaluation | name + data before top-1 / top-3 % | name + data after top-1 / top-3 % |
|---|---|---|
| held-out catalog names with data (89), series passed | 76.4 / 82.0 | 75.3 / 80.9 |
| real names, pooled (422) | 95.3 / 96.9 | 95.7 / 96.9 |
| real, "excluding in-sample names" (129) | 89.1 / 93.8 | 90.7 / 93.8 |
| simulated LBNL sets (78) | 78.2 / 85.9 | 78.2 / 85.9 |
| BTS anonymised (903), templates | 48.0 / 65.2 | 48.0 / 65.2 |
| BTS Brick-class labels as names (upper bound), templates | 95.5 / 99.9 | 95.5 / 99.9 |
synthetic VAV-2-14 DA-T |
73.9 / 86.3 | 74.0 / 86.7 |
synthetic 201-AHU3:SA-TMP |
74.0 / 85.3 | 74.0 / 85.9 |
The other synthetic styles, every name-only row and every data-only row are unchanged.
real_names.py, bts.py and messy_names.py score cached profiles without the series, so
there the range-check fix does not act. Only catalog_names.py --data passes the series.
- The setpoint guard removes the last real-name loss. Both
robodrooms'temp_setpointkeepcool_sp. On real names the data now helps 6 points and hurts none (0.100: 6 and 2). - The range check without a unit makes the time-series path judge a point's range as the
default path does, without the °F assumption. It gains
ch1_p_disand costs the two freezer-case temperatures, whose labels stretch an air-handler role below its physical bounds. - The range-check column is why the default switched (#107). Under 0.100, a caller who passed series without units to the default suggester lost 27 points of top-1 on these names (76.4 → 49.4 %). That range check reads every unitless temperature as °F, so 22 °C rooms and 13 °C supply air fall out of range. The time-series path keeps 75.3 %.
- The held-out pool with data is small (89 points), and one file (
ornl_frp_vav, 61 points) dominates it. Its runs are one day long, so the daily and weekly features are not measured.
0.101: the default switch, before and after (#107)
Every pool is re-scored at default settings: suggest_roles(name, series=series), with no unit,
no outdoor-air series and no suggester. "Before" is the 0.100 default with the series passed
(the name, the unit and the range check; from 0.101 the use_timeseries=False opt-out).
"After" is the 0.101 default. The no-series column is the default without a series, which does
not change. The series are passed whole here, so the range check acts in every pool (the
earlier tables score cached profiles).
| pool | points | default, no series top-1 / top-3 % | default with series, 0.100 top-1 / top-3 % | default with series, 0.101 top-1 / top-3 % | top-1 helped / hurt |
|---|---|---|---|---|---|
| held-out catalog names with series | 89 | 76.4 / 79.8 | 49.4 / 50.6 | 73.0 / 80.9 | 23 / 2 |
| real names | 422 | 94.3 / 95.0 | 84.8 / 85.5 | 95.7 / 96.9 | 46 / 0 |
| real names, excluding in-sample names | 129 | 86.0 / 88.4 | 57.4 / 59.7 | 90.7 / 93.8 | 43 / 0 |
| simulated LBNL sets | 78 | 71.8 / 85.9 | 73.1 / 85.9 | 74.4 / 85.9 | 1 / 0 |
| BTS anonymised | 903 | 0.0 / 0.0 | 0.0 / 0.0 | 46.2 / 64.6 | 417 / 0 |
| BTS Brick-class labels as names (upper bound) | 903 | 93.2 / 97.2 | 25.5 / 31.6 | 89.4 / 97.2 | 578 / 1 |
synthetic AHU1_SAT |
903 | 64.6 / 69.4 | 14.8 / 17.2 | 70.4 / 81.7 | 506 / 4 |
synthetic VAV-2-14 DA-T |
903 | 64.7 / 78.6 | 17.3 / 23.4 | 71.3 / 86.0 | 496 / 8 |
synthetic B2.L3.FCU07.RmTmp |
903 | 79.4 / 84.6 | 20.7 / 25.1 | 80.8 / 89.6 | 546 / 3 |
synthetic ahu_03_supply_temp |
903 | 93.9 / 96.9 | 25.9 / 30.6 | 89.9 / 96.2 | 579 / 1 |
synthetic 201-AHU3:SA-TMP |
903 | 63.3 / 77.0 | 16.6 / 23.1 | 70.5 / 86.6 | 492 / 5 |
Synthetic names are synthetic, and the BTS Brick-class rows are an upper bound, not real-world naming.
- Passing a series no longer costs a caller. Under 0.100 the default range check read every unitless temperature as °F, so passing °C data dropped top-1 to 15-57 % on most pools. From 0.101 the default with a series is at or above the name alone on every real, held-out and simulated pool except the held-out set (73.0 against 76.4 %, with top-3 80.9 against 79.8 %).
- The held-out figure is 73.0 %, not the 75.3 % above, because the default passes no outdoor-air series: the weather features are not measured.
- On strong names, the range check without a unit costs a little. With BTS Brick-class names
the default with a series reaches 89.4 %, below the name alone (93.2 %). The same series
without the range check reach 95.5 %. BTS site C's data has negative airflows and dropouts, so
the check demotes airflows to airflow setpoints (19 points) and a few CO2 and zone points. The
ahu_03_supply_tempstyle shows the same (93.9 → 89.9 %). Fixed in 0.102 (#110): see the next section.
0.102: the range check on dirty series (#110)
The same scoring as the 0.101 table: every pool at default settings,
suggest_roles(name, series=series) with the whole series, no unit, no outdoor-air series and
no suggester. "Before" is 0.101, "after" is 0.102. The no-series column does not change.
| pool | points | default, no series top-1 / top-3 % | default with series, 0.101 top-1 / top-3 % | default with series, 0.102 top-1 / top-3 % | top-1 helped / hurt |
|---|---|---|---|---|---|
| held-out catalog names with series | 89 | 76.4 / 79.8 | 73.0 / 80.9 | 73.0 / 80.9 | 0 / 0 |
| real names | 422 | 94.3 / 95.0 | 95.7 / 96.9 | 95.7 / 96.9 | 0 / 0 |
| real names, excluding in-sample names | 129 | 86.0 / 88.4 | 90.7 / 93.8 | 90.7 / 93.8 | 0 / 0 |
| simulated LBNL sets | 78 | 71.8 / 85.9 | 74.4 / 85.9 | 74.4 / 85.9 | 0 / 0 |
| BTS anonymised | 903 | 0.0 / 0.0 | 46.2 / 64.6 | 46.2 / 64.6 | 0 / 0 |
| BTS Brick-class labels as names (upper bound) | 903 | 93.2 / 97.2 | 89.4 / 97.2 | 93.4 / 99.9 | 36 / 0 |
synthetic AHU1_SAT |
903 | 64.6 / 69.4 | 70.4 / 81.7 | 73.0 / 84.1 | 24 / 1 |
synthetic VAV-2-14 DA-T |
903 | 64.7 / 78.6 | 71.3 / 86.0 | 73.3 / 86.8 | 20 / 2 |
synthetic B2.L3.FCU07.RmTmp |
903 | 79.4 / 84.6 | 80.8 / 89.6 | 82.7 / 90.8 | 17 / 0 |
synthetic ahu_03_supply_temp |
903 | 93.9 / 96.9 | 89.9 / 96.2 | 93.9 / 98.9 | 36 / 0 |
synthetic 201-AHU3:SA-TMP |
903 | 63.3 / 77.0 | 70.5 / 86.6 | 73.0 / 87.3 | 23 / 1 |
Synthetic names are synthetic, and the BTS Brick-class rows are an upper bound, not real-world naming.
- No pool loses top-1 or top-3. The held-out, real-name, simulated and anonymised pools keep
every top-1, so the held-out pool keeps its 0.101 gain.
catalog_names.py --data(name plus data with the series) is also unchanged. The only other change is a third suggestion: 15lbnl-b59zone fan speeds, often 0, now listspace_tempthird, because their zeros are dropouts for a temperature. - Strong names are back at or above the name alone. BTS Brick-class names read 93.4 % with
the series (93.2 % from the name alone; 89.4 % in 0.101), and the
ahu_03_supply_tempstyle 93.9 % (93.9 %; 89.9 % in 0.101). The 0.101 losses were site C's discharge airflows logged mostly negative (19 points, read as airflow setpoints), all-zero outdoor CO2 and zone temperature channels, zone temperatures at a fraction of their scale, and sentinel-laden chilled-water and hot-water points. - The 4 synthetic points that now miss are three airflow setpoints whose synthetic
abbreviation (
D-CFM-SPT,D-FLW-SPT) scoresairflowas the name's best role, and one chilled-water return (B8_CWRTMP) read as condenser water. In 0.101 the range check had demoted their negativeairflowreadings, and the setpoint, which has no physical bounds, won by that asymmetry. - Switching the unitless check off entirely scores a little higher on most pools (BTS
Brick-class 95.5 %, held-out 74.2 / 82.0 %), but real-name top-3 falls to 96.7 % and a
sentinel or dead channel (
-999) is no longer demoted, which #106 added the check for. The check stays and is capped instead. Floors from 0.5 to 1.0 give the same top-1 on every pool to within a point. The remaining gap is mostly weak names (On_Off_Statusread as a compressor status, 19 points), not strong ones. - The zero-dropout rule alone, without the cap, lifts BTS Brick-class names by under a point (90.3 %): the airflow losses are negative readings, not zeros. Dropping every exact zero, whatever the role, scores lower than 0.101, because 0 is a real reading for many roles.
Review the unmapped tags — review_unmapped
The front door for a whole tag set. It reuses mapping_confidence.review() to find the tags that
don't resolve, attaches ranked suggestions to each, and returns a human-confirm artifact:
from camber.mapping_assist import review_unmapped
rev = review_unmapped(
tokens, mapping, series_by_token={"VAV12_DmprPos": damper_series}, units={"VAV12_DmprPos": "%"}
)
rev["n_unmapped"] # how many didn't resolve
rev["review_list"] # [{"token", "suggestions": [RoleSuggestion.as_dict(), ...]}, ...]
The mapping is never modified — you review rev["review_list"], then apply the confirmed roles by
editing your mapping JSON.
Scale and unit evidence in mapping_confidence
score_token(token, mapping, series, unit=None) (and score_mapping / review with
units={token: unit}) cross-check a resolved mapping against the data:
- a declared °C / K temperature is converted before the physical-range check;
- a declared
%under a volumetric-flow role (airflow,oa_airflow,airflow_sp,chw_flow,hw_flow) is flaggedunit_mismatch(confidence × 0.3); - with no unit, a flow-role series that stays within 0–100 (≥ 99 % of samples in −2…102) is
flagged
percent_scale(confidence × 0.5, so an alias drops from "high" 0.95 to "low" 0.475) — the signature of a fan-speed or damper % point typed as a cfm/gpm flow, which the airflow bounds (−1…1e6) alone accept. A declaredcfm/gpmunit is trusted.
review() routes data_mismatch, unit_mismatch and percent_scale to needs_review.
Optional learned backend — MLSuggester
Behind the [ml] extra (pip install camber-toolkit[ml]), imported lazily so the core stays pure. A
character-n-gram scikit-learn classifier. It ships no pretrained weights (clean-room); you train
it on your own labels or bootstrap from an existing mapping:
from camber.mapping_assist import MLSuggester
ml = MLSuggester.from_mapping(mapping) # labels from mapping.aliases
# or: MLSuggester().fit([("AH1_SAT", "supply_air_temp"), ("RTU3_OAT", "oat"), ...])
suggest_roles("AH9_SAT", suggester=ml)
Learned predictions pass through the same physical-range gate as the baseline, so a confident
guess the data contradicts is still demoted. Accuracy scales with how many labels you provide; the
numpy FeatureSuggester remains the always-available floor.
Optional LLM backend — LLMSuggester
Reuses the provider-agnostic agent client — no new dependency, no vendor named. The
model sees the tag, its unit + bounded sample stats, and the whole Role vocabulary, and proposes
roles. Then the deterministic layer disposes:
- every proposal is validated
Role(value)— out-of-vocab proposals are dropped; - every surviving proposal is re-scored through
mapping_confidence.score_token, so a physically-inconsistent suggestion can't outrank a good one.
from camber.mapping_assist import LLMSuggester
from camber.agent import client_from_callable
client = client_from_callable(lambda p, **o: my_llm.complete(p)) # you own the SDK
suggest_roles("AH1_SAT", suggester=LLMSuggester(client), series=sat_series)
The LLM proposes; the deterministic layer is always the arbiter. See AGENT.md for the seam and its no-vendor/no-network guarantees.