Upload and stream tutorial#

Upload a race car’s recorded telemetry to a vehicle asset, stream live data to the same asset, and watch it arrive in a workbook. It uses two complete scripts, run from the terminal.

You need the installed client and a profile. The scripts take the profile name with --profile.

Upload a CSV to an asset#

  1. Download the sample data. Nominal ingests the gzipped CSV as it is:

    Download racecar_dataset.csv.gz

  2. Save this script as fsae_asset_upload.py. It finds or creates the FSAE CT8 Vehicle asset, uploads the CSV to its dataset and sets the channels’ units:

    """Create an asset and upload a CSV dataset to it.
    
    Creates (or reuses) an FSAE vehicle asset, uploads the racecar CSV from the
    [upload and stream tutorial](/guides/longstart.md) as its dataset, and tags each channel with its unit.
    Run it with `python fsae_asset_upload.py --file racecar_dataset.csv.gz --profile <profile>`.
    """
    
    import logging
    import sys
    import uuid
    from collections.abc import Mapping, Sequence
    
    import click
    
    from nominal.cli.util.global_decorators import client_options, global_options
    from nominal.core import Asset, NominalClient
    
    logger = logging.getLogger(__name__)
    
    
    def get_or_create_asset_or_guide_cleanup(
        client: NominalClient,
        properties: Mapping[str, str],
        *,
        name: str,
        description: str | None = None,
        labels: Sequence[str] = (),
    ) -> Asset:
        """Look up an asset by properties, creating one if none exists.
    
        Asset properties should uniquely identify the physical thing the asset
        represents. If multiple assets share these properties (for example,
        someone manually created a duplicate), log a red clean-up guide via
        the CLI's ClickLogHandler and exit.
        """
        try:
            return client.get_or_create_asset_by_properties(properties, name=name, description=description, labels=labels)
        except ValueError:
            conflicts = client.search_assets(properties=properties)
            conflict_lines = "\n".join(f"  - {a.name!r}: {a.nominal_url}" for a in conflicts)
            logger.error(
                f"Multiple assets in this workspace share properties {dict(properties)!r}:\n"
                f"{conflict_lines}\n"
                "\n"
                "Asset properties are key-value tags on an asset (for example, serial_num\n"
                "or car_number). They should uniquely identify the physical thing an asset\n"
                "represents, so a property search returns exactly one asset. Duplicates\n"
                "here mean an earlier tutorial session or another user already created an asset with\n"
                "these properties.\n"
                "\n"
                "Open each URL above, archive the ones you do not want to keep, then\n"
                "re-run this script. You can also archive from Python:\n"
                "\n"
                "    client.get_asset('<rid>').archive()"
            )
            sys.exit(1)
    
    
    @click.command()
    @click.option(
        "-f",
        "--file",
        "filepath",
        required=True,
        type=click.Path(exists=True, dir_okay=False),
        help="Path to the racecar_dataset.csv.gz file you downloaded.",
    )
    @client_options
    @global_options
    def main(filepath: str, client: NominalClient) -> None:
        """Create an FSAE asset in Nominal and upload a CSV dataset to it."""
        # Attach our own ingest tag to every point so the upload is easy to find
        # if others share the workspace. Nominal also attaches _nominal_ingest_rid
        # automatically, but it is surfaced only in the UI.
        session_id = str(uuid.uuid4())
        click.secho(
            f"\nTutorial session ID: {session_id}\n"
            f"Filter by tag 'tutorial_session_id' = '{session_id}' in Nominal\n"
            f"to isolate this upload.\n",
            fg="green",
            bold=True,
        )
    
        my_asset = get_or_create_asset_or_guide_cleanup(
            client,
            properties={"team": "Example Racing", "car_number": "8"},
            name="FSAE CT8 Vehicle",
            description="Asset for FSAE single asset tutorial",
            labels=["FSAE", "Vehicle"],
        )
        print("Using asset with RID: ", my_asset.rid)
    
        my_dataset = my_asset.get_or_create_dataset(
            # Data scope name (reference name) for this dataset within the asset.
            # This can be used later to retrieve this dataset by the same name.
            data_scope_name="Primary Data Source",
            # Human readable name shown in the Nominal UI.
            name="FSAE CT8 Vehicle dataset",
            # Optional long-form description shown on the dataset page.
            description="Dataset for FSAE single asset tutorial",
        )
        print("Created dataset with RID: ", my_dataset.rid)
    
        dataset_file = my_dataset.add_tabular_data(
            filepath,
            timestamp_column="Timestamps",
            timestamp_type="iso_8601",
            tags={"tutorial_session_id": session_id},
        )
        dataset_file.poll_until_ingestion_completed()
        print("Added CSV data to dataset! All Systems Nominal")
    
        # Tag every CSV channel that carries a unit in its column name using
        # strict UCUM symbols so Nominal can do unit conversions. UCUM quirks:
        # - psi      -> [psi]       (bracketed special unit)
        # - mph      -> [mi_i]/h    (international mile per hour)
        # - g-force  -> [g]         (standard gravity; bare "g" is UCUM for grams)
        # - rpm      -> /min        (reciprocal minute; 1 rpm = 1/60 Hz)
        my_dataset.set_channel_units(
            {
                "Distance_km": "km",
                "RR_Shock_mm": "mm",
                "RL_Shock_mm": "mm",
                "FL_Shock_mm": "mm",
                "FR_Shock_mm": "mm",
                "FL_Shock_Pos_Zero_mm": "mm",
                "FR_Shock_Pos_Zero_mm": "mm",
                "RL_Shock_Pos_Zero_mm": "mm",
                "RR_Shock_Pos_Zero_mm": "mm",
                "FL_Shock_Speed_mm/s": "mm/s",
                "FR_Shock_Speed_mm/s": "mm/s",
                "RL_Shock_Speed_mm/s": "mm/s",
                "RR_Shock_Speed_mm/s": "mm/s",
                "FL_Shock_Accel_mm/s/s": "mm/s2",
                "GPS_Altitude_m": "m",
                "GPS_PosAccuracy_m": "m",
                "AIM_DistanceMeters_m": "m",
                "GPS_Elevation_cm": "cm",
                "GPS_Latitude_°": "deg",
                "GPS_Longitude_°": "deg",
                "GPS_Slope_deg": "deg",
                "GPS_Heading_deg": "deg",
                "Re_Roll_Gradient_Degree": "deg",
                "GPS_Gyro_deg/s": "deg/s",
                "Battery_V": "V",
                "F88_V BATT_V": "V",
                "F88_BARO_PR_mbar": "mbar",
                "Rear_Brake_psi": "[psi]",
                "Front_brake_psi": "[psi]",
                "F88_OIL_P1_psi": "[psi]",
                "F88_FUEL_PR1_psi": "[psi]",
                "Run_Oil_Pres_psi": "[psi]",
                "Run_Oil_Pres_Hi_psi": "[psi]",
                "Load_Oil_Pres_psi": "[psi]",
                "Load_Oil_Pres_Hi_psi": "[psi]",
                "Load_Oil_Pres_Hi2_psi": "[psi]",
                "F88_V_SPEED_mph": "[mi_i]/h",
                "F88_D_SPEED_mph": "[mi_i]/h",
                "F88_SPEED_FL_mph": "[mi_i]/h",
                "F88_SPEED_FR_mph": "[mi_i]/h",
                "F88_SPEED_RL_mph": "[mi_i]/h",
                "F88_SPEED_RR_mph": "[mi_i]/h",
                "GPS_Speed_mph": "[mi_i]/h",
                "Acc_Lateral_g": "[g]",
                "Acc_Longitudin_g": "[g]",
                "GPS_LatAcc_g": "[g]",
                "GPS_LonAcc_g": "[g]",
                "F88_RPM_rpm": "/min",
                "Fuel Used_Liters": "L",
                "Fuel Flow_cc/min": "cm3/min",
            },
            allow_display_only_units=True,
        )
    
    
    if __name__ == "__main__":
        main()
    
  3. Run it:

    python fsae_asset_upload.py --profile <PROFILE> --file racecar_dataset.csv.gz
    
  4. In the Assets page, open FSAE CT8 Vehicle. The racecar_dataset file appears under Data sources:

Stream data to the asset#

  1. Save this script as stream_data_to_dataset.py. It streams a sine wave, temperature, CPU use and a cpu_info struct to the same dataset until you stop it. See Stream data for how streaming works.

    import logging
    import math
    import random
    import sys
    import time
    import uuid
    from collections.abc import Mapping, Sequence
    from datetime import datetime, timedelta
    
    import click
    from nominal.cli.util.global_decorators import client_options, global_options
    from nominal.core import Asset, NominalClient
    
    logger = logging.getLogger(__name__)
    
    
    def get_or_create_asset_or_guide_cleanup(
        client: NominalClient,
        properties: Mapping[str, str],
        *,
        name: str,
        description: str | None = None,
        labels: Sequence[str] = (),
    ) -> Asset:
        """Look up an asset by properties, creating one if none exists.
    
        Asset properties should uniquely identify the physical thing the asset
        represents. If multiple assets share these properties (for example,
        someone manually created a duplicate), log a red clean-up guide via
        the CLI's ClickLogHandler and exit.
        """
        try:
            return client.get_or_create_asset_by_properties(
                properties, name=name, description=description, labels=labels
            )
        except ValueError:
            conflicts = client.search_assets(properties=properties)
            conflict_lines = "\n".join(
                f"  - {a.name!r}: {a.nominal_url}" for a in conflicts
            )
            logger.error(
                f"Multiple assets in this workspace share properties {dict(properties)!r}:\n"
                f"{conflict_lines}\n"
                "\n"
                "Asset properties are key-value tags on an asset (for example, serial_num\n"
                "or car_number). They should uniquely identify the physical thing an asset\n"
                "represents, so a property search returns exactly one asset. Duplicates\n"
                "here mean an earlier tutorial session or another user already created an asset with\n"
                "these properties.\n"
                "\n"
                "Open each URL above, archive the ones you do not want to keep, then\n"
                "re-run this script. You can also archive from Python:\n"
                "\n"
                "    client.get_asset('<rid>').archive()"
            )
            sys.exit(1)
    
    
    @click.command()
    @client_options
    @global_options
    def main(client: NominalClient) -> None:
        """Stream continuous data to the FSAE tutorial asset's dataset."""
        # Attach our own streaming-session tag to every enqueued point so this
        # session is easy to find if others share the workspace. Nominal also
        # attaches _nominal_ingest_rid automatically, but it is surfaced only
        # in the UI.
        session_id = str(uuid.uuid4())
        click.secho(
            f"\nTutorial session ID: {session_id}\n"
            f"Filter by tag 'tutorial_session_id' = '{session_id}' in Nominal\n"
            f"to isolate this stream.\n",
            fg="green",
            bold=True,
        )
        tags = {"tutorial_session_id": session_id}
    
        asset = get_or_create_asset_or_guide_cleanup(
            client,
            properties={"team": "Example Racing", "car_number": "8"},
            name="FSAE CT8 Vehicle",
            description="Asset for FSAE single asset tutorial",
            labels=["FSAE", "Vehicle"],
        )
        print(f"Asset: {asset.name}, RID: {asset.rid}")
    
        dataset = asset.get_or_create_dataset(
            # Data scope name (reference name) for this dataset within the asset.
            # This can be used later to retrieve this dataset by the same name.
            data_scope_name="Primary Data Source",
            # Human readable name shown in the Nominal UI.
            name="FSAE CT8 Vehicle dataset",
            # Optional long-form description shown on the dataset page.
            description="Dataset for FSAE single asset tutorial",
        )
        print(f"Dataset: {dataset.name}, RID: {dataset.rid}")
    
        # Streams through Rust when nominal-streaming is installed (it is by default on
        # supported platforms), and falls back to pure Python otherwise.
        with dataset.get_write_stream() as stream:
            print(
                "Streaming all set up and queueing has started! Press ctrl+c to terminate streaming."
            )
            iteration = 0
            modes = ["Mode A", "Mode B", "Mode C"]
    
            while True:
                iteration += 1
                now = datetime.now()
    
                stream.enqueue(
                    channel_name="sine_wave",
                    timestamp=now,
                    value=math.sin(2 * math.pi * iteration / 90),
                    tags=tags,
                )
    
                if iteration % 2 == 0:
                    stream.enqueue_from_dict(
                        timestamp=now,
                        # Many channels written together at the same tick; useful when
                        # multiple sensors sample simultaneously.
                        channel_values={
                            # Simulated CPU temperature (degC): baseline 55 with a 10 degC
                            # sinusoidal load swing and small noise.
                            "temperature": 55
                            + 10 * math.sin(2 * math.pi * iteration / 60)
                            + random.uniform(-2, 2),
                            "mode": modes[iteration % 3],
                        },
                        tags=tags,
                    )
    
                # Nominal creates channels lazily on first write, so wait ~10s for
                # the server's channel index to catch up before setting units.
                # Values are UCUM symbols.
                if iteration == 100:
                    dataset.set_channel_units(
                        {
                            "sine_wave": "V",
                            "temperature": "Cel",
                            "cpu_utilization": "%",
                        }
                    )
    
                if iteration % 5 == 0:
                    stream.enqueue_struct(
                        # Channel stores one JSON-serializable dict per point.
                        channel_name="cpu_info",
                        timestamp=now,
                        # Whole dict is stored as one nested value at (channel, timestamp).
                        # Must be JSON-serializable.
                        value={
                            "frequency_ghz": round(2.4 + random.uniform(0, 2), 2),
                            "active_cores": random.randint(2, 8),
                            "governor": random.choice(
                                ["performance", "powersave", "schedutil"]
                            ),
                        },
                        tags=tags,
                    )
    
                if iteration % 10 == 0:
                    stream.enqueue_batch(
                        channel_name="cpu_utilization",
                        # Points are keyed by (channel, timestamp), so timestamps must
                        # be distinct or later writes overwrite earlier ones at the
                        # same key. Spread 5 points 200ms apart, ending at the
                        # current timestamp.
                        timestamps=[
                            now - timedelta(milliseconds=200 * (4 - i)) for i in range(5)
                        ],
                        # One value per timestamp, aligned by index. Scaled 0-100 to match the "%" unit.
                        values=[random.uniform(0, 100) for _ in range(5)],
                        tags=tags,
                    )
    
                time.sleep(0.1)
    
    
    if __name__ == "__main__":
        main()
    
  2. Run it, and leave it running for the next section:

    python stream_data_to_dataset.py --profile <PROFILE>
    

    It prints a session ID and tags every point with it as tutorial_session_id.

Watch the data arrive#

  1. Open FSAE CT8 Vehicle, click New workbook and select New empty workbook.

  2. Drag sine_wave, temperature or cpu_utilization from Channel search onto the chart.

  3. Click Go Live in the bottom left. New points appear as the script sends them.

cpu_info is a struct, so a time series chart can’t plot it directly. Drag it onto a Value grid panel, or transform it to a numeric field such as frequency_ghz.

If other people are running this tutorial in your workspace, show only your points with a tag filter:

  1. In the workbook’s Inputs panel, click + and set Type to Tag filter.

  2. Set Tag key to tutorial_session_id, paste your session ID into Tag values and click Save input.

  3. For channels you add later, click + next to the input and select Apply to all variables.

Adding a tutorial_session_id tag filter in the workbook Inputs panel

Press Ctrl+C to stop streaming.

Clean up#

Archive the asset from its options menu on the Assets page, or from Python:

client.get_asset("<ASSET_RID>").archive()

Archiving hides the asset without deleting its data. Only a workspace admin can delete the dataset.

To query the data with SQL, see the SQL interface.