NHTSA vPIC often returns engine-shaped fields: horsepower-ish values, displacement in liters or cubic centimeters, cylinder counts, fuel type. Product teams love putting a big "280 HP" badge on the decode card. Buyers love it too -- until they treat that badge like a dyno sheet from last Saturday.
This post is about honest display: surface EngineHP and displacement when vPIC provides them, label the source, and refuse to imply measured crank or wheel horsepower.
What vPIC is (and is not) saying
DecodeVinValues-style payloads may include fields such as EngineHP, DisplacementL, DisplacementCC, EngineCylinders, and fuel-related attributes. These are catalog / manufacturer-reported characteristics associated with the VIN decode, not a live measurement of the vehicle in front of you.
They do not mean:
- Your exact car made that number on a dyno today
- The engine is unmodified, healthy, or original
- SAE net vs gross vs marketing PS was reconciled for your UI
- Twin-turbo or tune status is reflected
If the field is empty, the car still has an engine -- vPIC simply did not give you a number. Empty is not zero HP.
The overclaim pattern
// Bad: marketing certainty from a single optional field
function badge(hp: string | undefined): string {
return `${hp || "200"} HP verified`;
}
Defaulting missing HP to a round number, appending "verified", or titling the section "Performance" trains users to over-trust a free decode. Prefer absence over fiction.
Pass through with units and provenance
export type EnginePowerView = {
hp: number | null;
displacementL: number | null;
displacementCc: number | null;
label: string;
};
const EMPTY = new Set(["", "null", "undefined", "not applicable", "n/a", "0"]);
function parsePositiveNumber(raw: string | null | undefined): number | null {
if (raw == null) return null;
const t = String(raw).trim();
if (!t || EMPTY.has(t.toLowerCase())) return null;
const n = Number(t);
if (!Number.isFinite(n) || n <= 0) return null;
return n;
}
export function viewEnginePower(fields: {
EngineHP?: string | null;
DisplacementL?: string | null;
DisplacementCC?: string | null;
}): EnginePowerView {
return {
hp: parsePositiveNumber(fields.EngineHP),
displacementL: parsePositiveNumber(fields.DisplacementL),
displacementCc: parsePositiveNumber(fields.DisplacementCC),
label: "Factory / vPIC catalog (not a dyno measurement)",
};
}
export function enginePowerLines(v: EnginePowerView): string[] {
const lines: string[] = [];
if (v.hp != null) lines.push(`Engine HP (vPIC): ${v.hp}`);
if (v.displacementL != null) {
lines.push(`Displacement: ${v.displacementL} L`);
} else if (v.displacementCc != null) {
lines.push(`Displacement: ${v.displacementCc} cc`);
}
if (lines.length === 0) {
return ["Engine power / displacement: not provided by NHTSA for this VIN"];
}
lines.push(v.label);
return lines;
}
Show HP and displacement on separate rows. If both liter and cc exist and disagree after conversion, prefer showing both raw values rather than silently picking one -- inconsistency is data, not something to hide behind a single "2.0L" chip.
UI copy that keeps you honest
Good headings:
- "Engine (NHTSA vPIC)"
- "Reported horsepower (catalog)"
Avoid:
- "Dyno"
- "Wheel HP"
- "Verified performance"
- "Stock power guaranteed"
A one-line footnote near the badge is enough: "Catalog value from NHTSA vPIC; not measured on this vehicle."
Do not invent HP from displacement
// Bad: folklore regression
export function guessHpFromLiters(liters: number): number {
return Math.round(liters * 100);
}
Displacement-to-HP heuristics belong in games, not in a decode product. Two engines with the same liter rating can differ by hundreds of horsepower across forced induction, duty cycle, and rating standards.
Combine with other engine fields without merging myths
export function engineSummary(fields: {
EngineHP?: string | null;
DisplacementL?: string | null;
EngineCylinders?: string | null;
FuelTypePrimary?: string | null;
}): Array<{ k: string; v: string }> {
const power = viewEnginePower(fields);
const rows: Array<{ k: string; v: string }> = [];
rows.push({
k: "Engine HP (vPIC)",
v: power.hp != null ? String(power.hp) : "Not provided",
});
rows.push({
k: "Displacement",
v:
power.displacementL != null
? `${power.displacementL} L`
: power.displacementCc != null
? `${power.displacementCc} cc`
: "Not provided",
});
const cyl = (fields.EngineCylinders ?? "").trim();
const fuel = (fields.FuelTypePrimary ?? "").trim();
if (cyl) rows.push({ k: "Cylinders (vPIC)", v: cyl });
if (fuel) rows.push({ k: "Fuel (vPIC)", v: fuel });
return rows;
}
Keep cylinders and fuel on their own keys. Do not concatenate into "V6 280HP Sport Fuel System" unless every token came from the API.
Tests that lock honesty
import assert from "node:assert/strict";
assert.equal(viewEnginePower({ EngineHP: "" }).hp, null);
assert.equal(viewEnginePower({ EngineHP: "0" }).hp, null);
assert.equal(viewEnginePower({ EngineHP: "310" }).hp, 310);
assert.ok(
enginePowerLines(viewEnginePower({ EngineHP: "310" })).some((l) =>
/not a dyno/i.test(l),
),
);
Add a review check: no user-visible string in the engine module may contain "dyno", "verified HP", or "wheel" unless it is the disclaimer denying those meanings.
Takeaway
EngineHP and displacement from vPIC are catalog fields. Display them with clear sourcing, allow "not provided," and never default or guess horsepower from liters. Your free VIN UI stays trustworthy when a big number is either a real API value with a modest label -- or absent.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)