Every plant has a tag named something like N7_42_TT01. Somebody knew what it meant a decade or more ago. Today it's one of 40,000 tags on an OPC UA server, and your data team is trying to figure out whether it's a temperature, which press it belongs to, and what unit it's in.
Raw tags are cheap to collect and expensive to use. The value shows up when every value carries context: which site, which line, which machine, what it measures, and in what unit. That context is what "tagging" really means on the plant floor.
In this tutorial you'll build that context layer on AWS. You'll define an ISA-95 style naming convention, keep the tag-to-asset mapping in a version-controlled CSV, model your equipment in AWS IoT SiteWise with CloudFormation, ingest tags through a SiteWise Edge gateway, and bind every data stream to the right asset property with a short Python script. At the end you'll query the data with SQL by asset name instead of by tag address.
You don't need a plant or a gateway to follow along. Step 3 includes a small simulator that writes realistic raw tags to SiteWise exactly the way a gateway would.
All the code is on GitHub: scooteronce/sitewise-tag-mapping.
Before you start: you need an AWS account, the AWS CLI, Python 3 with boto3, and credentials that can manage IoT SiteWise and CloudFormation. Pick one Region where IoT SiteWise is available and use it for everything. The examples use us-west-2:
export AWS_REGION=us-west-2 Please Read This: AWS IoT SiteWise isn't part of the AWS Free Tier. It bills for messages, metric computations, storage, and queries. Running the companion repo end to end with 24 hours of simulated history costs a few cents. Run the teardown at the end when you're done. While it's cheap to run this demo, I don't want to catch anyone off guard
The problem with raw tags
A PLC tag name is an address, not a description. A controls engineer wrote it to make ladder logic work, not to make analytics easy. A typical OPC UA browse shows a mix like this:
Channel1.Press01.N7_42_TT01 Channel1.Press01.Hyd_Press_PSI Kepware.Line3.Press_2.CycleCnt PLC_04.DB12.DBD8 Line3_Press02_Motor_Amps_RAW Five tags, four naming styles, two vendors, and one tag that's just a memory address. Multiply that across a few thousand machines and three sites, and you get the same set of problems everywhere:
- No hierarchy. You can't ask "average hydraulic pressure for every press on Line 3" because nothing says which tags belong to Line 3.
- No units or types. Is
Hyd_Press_PSIreally PSI? IsRAWscaled? - No stability. Re-flash a PLC or migrate to a new OPC server and the addresses change. Every dashboard and model that referenced them breaks.
- Tribal knowledge. The mapping lives in a spreadsheet on one engineer's laptop.
A good tagging strategy fixes all four. Each value gets a stable, human-readable identity in a hierarchy. The physical address becomes an implementation detail that you can change without breaking consumers. And the mapping lives in Git, where it gets reviewed like code.
Architecture
Raw tags flow left to right into IoT SiteWise as data streams. A mapping file in Git, applied by a small script, binds each stream to a named property on an asset in your hierarchy. Consumers only ever see the hierarchy.
Step 1: Define a naming convention and a mapping file
Start with the hierarchy, not the tags. ISA-95 gives you a vocabulary that most plants already recognize:
| ISA-95 level | Example | Becomes in SiteWise |
|---|---|---|
| Site | slc | Site asset (top of the hierarchy) |
| Area | stamping | Area asset |
| Line | line3 | Line asset |
| Equipment | press01 | Equipment asset |
| Measurement | hydraulic_pressure | Measurement property on the equipment model |
ISA-95 also defines an Enterprise level above Site. This tutorial starts at Site to keep things short. Add an Enterprise model the same way if you run multiple companies or business units.
A few rules keep the convention usable:
- Lowercase snake_case only. No spaces, no vendor prefixes, no memory addresses.
- Measurement names describe the physical quantity, not the sensor:
hydraulic_pressure, notPT_101. - Units live in the model, not the name.
hydraulic_pressurewith unitpsi, nothyd_press_psi. - Same equipment type, same property names. Every press has
oil_temperature,hydraulic_pressure,motor_current, andcycle_count, whatever the PLC calls them.
Now write the mapping file. This CSV is the source of truth that connects each raw data stream to a place in the hierarchy. Commit it to the same repo as your infrastructure code.
stream_alias,site,area,line,equipment,equipment_type,property /slc-kep01/Channel1/Press01/N7_42_TT01,slc,stamping,line3,press01,press,oil_temperature /slc-kep01/Channel1/Press01/Hyd_Press_PSI,slc,stamping,line3,press01,press,hydraulic_pressure /slc-kep01/Channel1/Press01/Motor_Amps_RAW,slc,stamping,line3,press01,press,motor_current /slc-kep01/Channel1/Press01/CycleCnt,slc,stamping,line3,press01,press,cycle_count /slc-kep01/Channel1/Press02/TT_OIL,slc,stamping,line3,press02,press,oil_temperature /slc-kep01/Channel1/Press02/HYD_P,slc,stamping,line3,press02,press,hydraulic_pressure /slc-kep01/Channel1/Press02/MTR_I,slc,stamping,line3,press02,press,motor_current /slc-kep01/Channel1/Press02/CYCLES,slc,stamping,line3,press02,press,cycle_count Notice that Press01 and Press02 use completely different tag names, but both map to the same four property names. That's the whole point. The left column can be ugly. Everything to the right of it is your contract with downstream consumers.
The /slc-kep01 prefix comes from the SiteWise Edge data source configuration in Step 3. It keeps aliases unique when two OPC UA servers expose the same paths.
Step 2: Model the equipment in IoT SiteWise
Asset models are the types: what a press is and what it measures. Assets are the instances: Press01 on Line 3 in Salt Lake City. Models change rarely and deserve infrastructure-as-code. Assets scale with your mapping file, so we'll create those from the CSV in Step 4.
This CloudFormation template defines four models (Site, Area, Line, Press) and the hierarchy between them. Every model, property, and hierarchy gets an external ID. External IDs are identifiers you choose. Most SiteWise APIs, including every call the scripts in this article make, accept them in place of a generated UUID when you add the externalId: prefix. A few operations still require the UUID, so check the API reference before you rely on it elsewhere.
AWSTemplateFormatVersion: "2010-09-09" Description: ISA-95 asset models for manufacturing tag data Resources: PressModel: Type: AWS::IoTSiteWise::AssetModel Properties: AssetModelName: Press AssetModelExternalId: model-press AssetModelProperties: - Name: oil_temperature ExternalId: press-oil_temperature DataType: DOUBLE Unit: degF Type: { TypeName: Measurement } - Name: hydraulic_pressure ExternalId: press-hydraulic_pressure DataType: DOUBLE Unit: psi Type: { TypeName: Measurement } - Name: motor_current ExternalId: press-motor_current DataType: DOUBLE Unit: A Type: { TypeName: Measurement } - Name: cycle_count ExternalId: press-cycle_count DataType: INTEGER Unit: count Type: { TypeName: Measurement } LineModel: Type: AWS::IoTSiteWise::AssetModel Properties: AssetModelName: Line AssetModelExternalId: model-line AssetModelHierarchies: - Name: presses ExternalId: line-presses ChildAssetModelId: !GetAtt PressModel.AssetModelId AreaModel: Type: AWS::IoTSiteWise::AssetModel Properties: AssetModelName: Area AssetModelExternalId: model-area AssetModelHierarchies: - Name: lines ExternalId: area-lines ChildAssetModelId: !GetAtt LineModel.AssetModelId SiteModel: Type: AWS::IoTSiteWise::AssetModel Properties: AssetModelName: Site AssetModelExternalId: model-site AssetModelHierarchies: - Name: areas ExternalId: site-areas ChildAssetModelId: !GetAtt AreaModel.AssetModelId Deploy it:
aws cloudformation deploy \ --template-file asset-models.yaml \ --stack-name sitewise-asset-models If you add a second equipment type later (a welder, a CNC, an oven), add a model for it and a hierarchy on LineModel. If several equipment types share core measurements, look at SiteWise asset model interfaces, which let you define a standard set of properties once and apply it across models.
Step 3: Get raw tags into SiteWise
In production, a SiteWise Edge gateway browses your OPC UA server and creates one data stream per variable. Each stream's alias is the browse path under the Objects node, with an optional prefix in front. Streams arrive before any asset exists. They sit as unassociated data streams until you bind them to an asset property, which is exactly the gap Step 4 fills.
You don't need a gateway to see this work. Option A simulates one. Option B is the real setup.
Option A: Simulate the gateway (recommended for this tutorial)
simulate_tags.py writes values for the eight raw aliases in the CSV straight to SiteWise by alias, with deliberately inconsistent tag names on each press. To SiteWise, the result looks the same as gateway output: disassociated data streams with ugly names. It only needs boto3 and your credentials.
""" Publish simulated press tag data straight to AWS IoT SiteWise, standing in for a SiteWise Edge gateway. Values are written by property alias, so each tag shows up as a disassociated data stream, exactly what an OPC UA source on a gateway would produce. The raw PLC tag names are deliberately inconsistent between presses (e.g. /slc-kep01/Channel1/Press01/N7_42_TT01 vs /slc-kep01/Channel1/Press02/TT_OIL); tag-map.csv is what gives them meaning. python simulate_tags.py --backfill-hours 24 # history for metrics and queries python simulate_tags.py --live # keep sending every 10s until Ctrl-C Uses the standard AWS credential chain (AWS_PROFILE, AWS_REGION, ...). """ import argparse import math import os import random import time import zlib import boto3 MAX_ENTRIES = 10 # BatchPutAssetPropertyValue: entries per request MAX_VALUES = 10 # values per entry MAX_BACKFILL_HOURS = 167 # SiteWise rejects timestamps older than 7 days # measurement -> (SiteWise value type, generator(t_seconds, device_seed)) MEASUREMENTS = { 'oil_temperature': ('doubleValue', lambda t, s: 120 + 8 * math.sin(2 * math.pi * (t + s * 3600) / 86400) + random.gauss(0, 1.0)), 'hydraulic_pressure': ('doubleValue', lambda t, s: 2200 + 150 * math.sin(2 * math.pi * t / 900) + random.gauss(0, 40)), 'motor_current': ('doubleValue', lambda t, s: 45 + 5 * math.sin(2 * math.pi * t / 600) + random.gauss(0, 3.0)), # Derived from the clock so it only ever goes up, even across backfill and live runs 'cycle_count': ('integerValue', lambda t, s: int(t // 6) - 290_000_000 + s * 1000), } # device -> measurement -> raw PLC tag name, as the OPC UA server would expose it RAW_TAGS = { 'Press01': {'oil_temperature': 'N7_42_TT01', 'hydraulic_pressure': 'Hyd_Press_PSI', 'motor_current': 'Motor_Amps_RAW', 'cycle_count': 'CycleCnt'}, 'Press02': {'oil_temperature': 'TT_OIL', 'hydraulic_pressure': 'HYD_P', 'motor_current': 'MTR_I', 'cycle_count': 'CYCLES'}, } def device_seed(device): return zlib.crc32(device.encode()) % 24 def property_value(measurement, t, seed): value_type, generate = MEASUREMENTS[measurement] return { 'value': {value_type: generate(t, seed)}, 'timestamp': {'timeInSeconds': int(t), 'offsetInNanos': 0}, 'quality': 'GOOD', } def build_entries(prefix, devices, timestamps): """ One entry per (alias, chunk of up to MAX_VALUES timestamps), interleaved across aliases so a request never piles onto one stream (SiteWise rate-limits per stream) """ streams = [(device, measurement, tag) for device in devices for measurement, tag in RAW_TAGS[device].items()] entries = [] for i in range(0, len(timestamps), MAX_VALUES): chunk = timestamps[i:i + MAX_VALUES] for device, measurement, tag in streams: seed = device_seed(device) entries.append({ 'entryId': f'{device}-{tag}-{i}'[:64], 'propertyAlias': f'{prefix}/{device}/{tag}', 'propertyValues': [property_value(measurement, t, seed) for t in chunk], }) return entries def send(client, entries, max_attempts=5): """ Returns the number of entries that still failed after retrying throttled ones """ errors = {} for i in range(0, len(entries), MAX_ENTRIES): batch = entries[i:i + MAX_ENTRIES] for attempt in range(max_attempts): response = client.batch_put_asset_property_value(entries=batch) failed = {e['entryId']: e['errors'][0] for e in response.get('errorEntries', [])} retry = [entry for entry in batch if failed.get(entry['entryId'], {}).get('errorCode') == 'ThrottlingException'] for entry_id, error in failed.items(): if error['errorCode'] != 'ThrottlingException': errors[entry_id] = error if not retry: break batch = retry time.sleep(0.5 * 2 ** attempt) else: errors.update({entry['entryId']: failed[entry['entryId']] for entry in batch}) for entry_id, error in list(errors.items())[:5]: print(f" {entry_id}: {error['errorCode']} {error['errorMessage']}") return len(errors) def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument('--prefix', default='/slc-kep01/Channel1', help='alias prefix: gateway measurementDataStreamPrefix + OPC UA browse path') parser.add_argument('--devices', default=','.join(RAW_TAGS), help=f'comma-separated subset of {",".join(RAW_TAGS)}') parser.add_argument('--backfill-hours', type=float, default=0, help=f'write history first (max {MAX_BACKFILL_HOURS})') parser.add_argument('--backfill-step', type=int, default=60, help='seconds between backfilled points') parser.add_argument('--live', action='store_true', help='keep publishing current values until Ctrl-C') parser.add_argument('--interval', type=int, default=10, help='seconds between live publishes') args = parser.parse_args() if not args.backfill_hours and not args.live: parser.error('pass --backfill-hours and/or --live') if args.backfill_hours > MAX_BACKFILL_HOURS: parser.error(f'--backfill-hours cannot exceed {MAX_BACKFILL_HOURS} (SiteWise 7-day limit)') # boto3 only reads AWS_DEFAULT_REGION on its own; also honor AWS_REGION like the CLI does client = boto3.client('iotsitewise', region_name=os.environ.get('AWS_REGION')) devices = [d.strip() for d in args.devices.split(',') if d.strip()] unknown = set(devices) - set(RAW_TAGS) if unknown: parser.error(f'unknown devices: {", ".join(sorted(unknown))}') for device in devices: print(f'{args.prefix}/{device}/{{{",".join(RAW_TAGS[device].values())}}}') if args.backfill_hours: now = int(time.time()) start = now - int(args.backfill_hours * 3600) timestamps = list(range(start, now, args.backfill_step)) entries = build_entries(args.prefix, devices, timestamps) print(f'Backfilling {len(timestamps)} points per stream ({len(entries)} entries)...') failures = send(client, entries) print(f'Backfill done, {failures} failed entries') if args.live: print(f'Publishing every {args.interval}s, Ctrl-C to stop') try: while True: failures = send(client, build_entries(args.prefix, devices, [time.time()])) print(f"Source:https://dev.to/scott_peterson/tagging-manufacturing-data-on-aws-from-plc-tags-to-iot-sitewise-asset-models-53 