Unified ManufacturingData Architecture

UMDA Tools

Unified Namespace Designer

Design a governed, ISA-95 aligned Unified Namespace. Set one naming convention, build the plant hierarchy, attach data typed to the UMDA entities, and export a naming standard your team can hold to. Switch between a semantic MQTT tree and Sparkplug B to see how the same plant looks in each.

Loading the designer… if this message does not go away after a few seconds, the page did not finish downloading: refresh to reload it.

Work the tabs left to right: set the naming convention, build the namespace tree, then validate, visualize, and export the standard.

"; } function printOnePager(){ try { const w = (typeof window !== "undefined" && window.open) ? window.open("", "_blank") : null; if(!w){ toast("Popup blocked, downloading the one-pager instead"); download(safeName()+"-uns-summary.html", execOnePagerDoc(), "text/html"); return; } w.document.write(execOnePagerDoc()); w.document.close(); setTimeout(() => { try { w.print(); } catch(e){} }, 300); } catch(e){ download(safeName()+"-uns-summary.html", execOnePagerDoc(), "text/html"); } } function renderExport(v){ let h = ""; h += '
'; h += 'Export your designOpen this to download the naming standard, topics, payload schema, AsyncAPI, library, or design file'; h += '
'; h += '

Everything you build can leave as a document or a data file. The naming standard captures the convention, hierarchy, topics, and governance, all generated from this design.

'; h += '
'; h += '

Naming standard

The full document, Markdown.

'; h += '

Topic list and data dictionary

Plain text, or a CSV with a column per level — or send the governed topics straight to the Edge Node Planner to map raw PLC tags onto them.

'; h += '

Subscriptions by consumer

Which system reads which topics, long format for a sheet. Reflects your edited systems.

'; h += '

Tree diagram

Mermaid, paste into any Mermaid viewer.

'; h += '

Design file

Save and reload your work.

'; h += '

Share link

A link that reopens this exact design in the browser. Long for big plants, since the whole design travels in the link.

'; h += '

Payload template

A sample message for the current target.

'; h += '
'; h += '

Contracts and reuse

'; h += '
'; h += '

Payload schema

JSON Schema for the message envelope.

'; h += '

AsyncAPI

One channel per signal, an artifact platform teams know.

'; h += '

Library, reusable IP

Your entities and signals, to reuse across projects — or send them straight to another designer, no file needed: the CDM Designer turns them into a data model, the Agent Designer into its entity catalog.

'; h += '
'; h += '

Platform starters

'; h += '

Opinionated starting points for the common platforms. Each is a draft to adapt, not a finished integration.

'; h += '
'; h += '

Broker ACL

Mosquitto access control. Producers write their subtree, consumers read what they subscribe to.

'; h += '

Node-RED flow

An importable flow with an mqtt in and a debug node per consumer subscription.

'; h += '

Ignition tags

A tag manifest CSV mirroring the namespace, with mapped data types and source topics.

'; h += '

OPC-UA browse paths

A browse path and a NodeId hint per signal, with mapped OPC-UA data types.

'; h += '

Executive one-pager

A printable summary of readiness, scale, gaps, and governance for a stakeholder.

'; h += '
'; h += '
'; h += '

Preview

'; h += '
Preview
'; EXPORT_PREVIEWS.forEach(p => { h += ''; }); h += '
'; h += exportPreviewHtml(); h += '

Plan a migration from existing topics

'; h += '

Paste the topics published today. The tool maps them onto this design, so you get a rename table, the topics to retire, and the new ones to add, plus a downloadable plan.

'; h += ''; h += '
'; h += ''; if(state.migResult) h += ''; h += '
'; if(state.migResult) h += migrationHtml(state.migResult); v.innerHTML = h; v.querySelectorAll("[data-exprev]").forEach(b => b.addEventListener("click", () => { state.exPrev = b.dataset.exprev; render(); })); v.querySelector("#dlStd").addEventListener("click", () => download(safeName()+"-uns-standard.md", namingStandardMd(), "text/markdown")); v.querySelector("#cpStd").addEventListener("click", () => copyText(namingStandardMd(), "Standard copied")); v.querySelector("#dlTxt").addEventListener("click", () => download(safeName()+"-topics.txt", topicsText(), "text/plain")); v.querySelector("#dlCsv").addEventListener("click", () => download(safeName()+"-data-dictionary.csv", topicsCsv(), "text/csv")); v.querySelector("#dlSubs").addEventListener("click", () => download(safeName()+"-subscriptions.csv", subscriptionsCsv(), "text/csv")); v.querySelector("#cpSubs").addEventListener("click", () => copyText(subscriptionsCsv(), "Subscriptions copied")); v.querySelector("#dlMmd").addEventListener("click", () => download(safeName()+"-tree.mmd", mermaidTree(), "text/plain")); v.querySelector("#cpMmd").addEventListener("click", () => copyText(mermaidTree(), "Mermaid copied")); v.querySelector("#dlJson").addEventListener("click", () => download(safeName()+"-uns-design.json", designJson(), "application/json")); v.querySelector("#cpShare").addEventListener("click", () => copyText(shareLink(), "Share link copied")); v.querySelector("#dlAcl").addEventListener("click", () => download(safeName()+"-uns.acl", mosquittoAcl(), "text/plain")); v.querySelector("#cpAcl").addEventListener("click", () => copyText(mosquittoAcl(), "ACL copied")); v.querySelector("#dlNodered").addEventListener("click", () => download(safeName()+"-node-red-flow.json", nodeRedFlow(), "application/json")); v.querySelector("#cpNodered").addEventListener("click", () => copyText(nodeRedFlow(), "Flow copied")); v.querySelector("#dlIgnition").addEventListener("click", () => download(safeName()+"-ignition-tags.csv", ignitionTagsCsv(), "text/csv")); v.querySelector("#dlOpcua").addEventListener("click", () => download(safeName()+"-opcua-browse-paths.csv", opcuaCsv(), "text/csv")); v.querySelector("#printOnePager").addEventListener("click", printOnePager); v.querySelector("#dlOnePager").addEventListener("click", () => download(safeName()+"-uns-summary.html", execOnePagerDoc(), "text/html")); v.querySelector("#loadJson").addEventListener("change", (e) => { const f = e.target.files && e.target.files[0]; if(!f) return; const rd = new FileReader(); rd.onload = () => { try { loadDesign(JSON.parse(rd.result)); toast("Design loaded"); state.tab="designer"; render(); } catch(err){ toast("That file did not load: " + err.message); } }; rd.readAsText(f); }); v.querySelector("#dlPay").addEventListener("click", () => download(safeName()+"-payload.json", payloadSchemaText(), "application/json")); v.querySelector("#cpPay").addEventListener("click", () => copyText(payloadSchemaText(), "Payload copied")); v.querySelector("#dlSchema").addEventListener("click", () => download(safeName()+"-payload.schema.json", jsonSchemaText(), "application/json")); v.querySelector("#cpSchema").addEventListener("click", () => copyText(jsonSchemaText(), "Schema copied")); v.querySelector("#dlAsync").addEventListener("click", () => download(safeName()+"-asyncapi.json", asyncApiText(), "application/json")); v.querySelector("#cpAsync").addEventListener("click", () => copyText(asyncApiText(), "AsyncAPI copied")); v.querySelector("#dlLib").addEventListener("click", () => download(safeName()+"-uns-library.json", libraryExport(), "application/json")); // one-click cross-tool handoff: stash the library for the Agent Designer (same origin), which // greets the user with an import banner — replaces downloading + re-uploading the file const sa = v.querySelector("#sendAgent"); if(sa) sa.addEventListener("click", () => { try{ localStorage.setItem("umda_handoff", JSON.stringify({ to:"agent-designer", from:"UNS Designer", name: state.tree.name || "", payload: JSON.parse(libraryExport()), at: Date.now() })); location.href = "agent-designer.html"; }catch(e){ toast("Could not hand the library off: " + e.message); } }); const sc = v.querySelector("#sendCdm"); if(sc) sc.addEventListener("click", () => { try{ localStorage.setItem("umda_handoff", JSON.stringify({ to:"cdm-designer", from:"UNS Designer", name: state.tree.name || "", payload: JSON.parse(libraryExport()), at: Date.now() })); location.href = "cdm-designer.html"; }catch(e){ toast("Could not hand the library off: " + e.message); } }); const se = v.querySelector("#sendEdge"); if(se) se.addEventListener("click", () => { try{ const payload = { tool:"UMDA UNS Topics", version:1, plant: state.tree.name || "", topics: collectSignals().map(x => ({ topic: signalTopicMqtt(x.node.id, x.sig), entity: x.sig.entity || "", signal: x.sig.name, dtype: x.sig.dtype, units: x.sig.units || "", update: x.sig.update || "" })) }; if(!payload.topics.length){ toast("No topics yet — build the tree first"); return; } localStorage.setItem("umda_handoff", JSON.stringify({ to:"edge-planner", from:"UNS Designer", name: state.tree.name || "", payload: payload, at: Date.now() })); location.href = "edge-node-planner.html"; }catch(e){ toast("Could not hand the topics off: " + e.message); } }); v.querySelector("#loadLib").addEventListener("change", (e) => { const f = e.target.files && e.target.files[0]; if(!f) return; const rd = new FileReader(); rd.onload = () => { try { const r = libraryImport(JSON.parse(rd.result)); toast("Library imported, " + r.addedEnt + " new entities"); render(); } catch(err){ toast("That library did not load: " + err.message); } }; rd.readAsText(f); }); const mt = v.querySelector("#migText"); if(mt && state.migText) mt.value = state.migText; const rm = v.querySelector("#runMig"); if(rm) rm.addEventListener("click", () => { const t = v.querySelector("#migText"); state.migText = t ? t.value : ""; state.migResult = migrationDiff(state.migText); render(); }); const dm = v.querySelector("#dlMig"); if(dm) dm.addEventListener("click", () => download(safeName()+"-migration-plan.csv", migrationCsv(state.migResult), "text/csv")); } /* =================================================================== SCENARIOS: worked plants across modes and industries =================================================================== */ /* ---- scenario builders: compact helpers that expand to realistic sites ---- */ function _sx(){ const out=[]; for(let i=0;iout.push(x)); else if(a) out.push(a); } return out; } function C(name, sigs){ return node("cell", name, [], sigs||[]); } function L(name, cells){ return node("line", name, cells||[]); } function A(name, lines){ return node("area", name, lines||[]); } function Ln(base, n, mk){ const out=[]; for(let i=1;i<=n;i++) out.push(node("line", base+" "+i, mk(i))); return out; } function eqB(){ return [ sig("State","equipment","String","","on change"), sig("Fault Code","equipment","String","","on change"), sig("Mode","equipment","String","","on change") ]; } function opR(u){ return [ sig("Rate","operation","Float",u||"units/min","periodic"), sig("Cycle Count","operation","Int","count","on change") ]; } function lotS(nm){ return [ sig(nm||"Current Lot","lot","String","","on change") ]; } function oprS(){ return [ sig("Operator","personnel","String","","on change"), sig("Shift","personnel","String","","on change") ]; } function qaS(){ return [ sig("Result","quality_test","String","","on change"), sig("Measurement","quality_test","Float","","periodic"), sig("Pass","quality_test","Boolean","","on change") ]; } function rejS(){ return [ sig("Reject Count","nonconformance","Int","count","on change"), sig("Defect","nonconformance","String","","on change") ]; } function _cpgSite(name){ return node("site", name, [ A("Processing", Ln("Mix", 2, i=>[ C("Blender", _sx(eqB(), lotS("Batch Id"), [sig("Viscosity","quality_test","Float","cP","periodic"), sig("Recipe","recipe","String","","on change")])), C("Hold Tank", [sig("Level","inventory","Float","pct","periodic"), sig("Temperature","process","Float","degC","periodic")]) ])), A("Packaging", Ln("Pack", 3, i=>[ C("Filler", _sx(eqB(), opR("units/min"), lotS("Lot"))), C("Checkweigher", _sx(qaS())), C("Case Packer", _sx(eqB(), [sig("Cases Out","material_movement","Int","cases","on change")])), C("Palletizer", _sx(eqB(), [sig("Pallet Id","shipment","String","","on change")])) ])), A("Warehouse", [ L("Storage", [ C("Rack A", [sig("On Hand","inventory","Int","cases","periodic"), sig("Shipment","shipment","String","","on change")]), C("Rack B", [sig("On Hand","inventory","Int","cases","periodic")]), C("Rack C", [sig("Demand","demand","Int","cases","periodic")]) ]) ]) ]); } const SCENARIOS = [ { id:"food", name:"Food and beverage packaging", mode:"Discrete", industry:"Food", conv:{ casing:"kebab", target:"mqtt", includeEnterprise:true }, blurb:"Mixing through packaging and warehouse. Fill, cap, label, case pack, with reject tracking and pallet handoff.", notes:["The lot is the trace unit and rides as a signal, never as a topic level","Case and pallet handoffs map to material movement and shipment","Kebab case keeps floor dashboards readable"], build:()=> node("enterprise","Acme Foods",[ node("site","Dayton",[ A("Mixing", Ln("Batch Line",2, i=>[ C("Mixer", _sx(eqB(), lotS("Batch Id"), [sig("Recipe Id","recipe","String","","on change"), sig("Temperature","process","Float","degC","periodic"), sig("Agitator Speed","operation","Float","rpm","periodic"), sig("Phase","process_segment","String","","on change")])), C("Pasteurizer", _sx(eqB(), [sig("Hold Temp","process","Float","degC","periodic"), sig("Flow","material_movement","Float","L/min","periodic")])), C("Holding Tank", [sig("Level","inventory","Float","pct","periodic"), sig("Temperature","process","Float","degC","periodic")]), C("CIP Skid", _sx(eqB(), [sig("CIP State","event","String","","on change"), sig("Conductivity","quality_test","Float","mS","periodic")])) ])), A("Packaging", Ln("Line",3, i=>[ C("Depalletizer", _sx(eqB(), opR("cases/min"))), C("Filler", _sx(eqB(), opR("units/min"), lotS("Current Lot"), oprS())), C("Capper", _sx(eqB(), [sig("Torque","quality_test","Float","Nm","periodic")], rejS())), C("Labeler", _sx(eqB(), [sig("Label Verify","quality_test","Boolean","","on change"), sig("Product","product","String","","on change")])), C("Checkweigher", _sx(qaS(), rejS())), C("Case Packer", _sx(eqB(), [sig("Cases Out","material_movement","Int","cases","on change"), sig("Pallet Id","shipment","String","","on change")])) ])), A("Warehouse", [ L("Receiving", [ C("Dock 1", [sig("Receipt","material_movement","String","","on change"), sig("Purchase Order","order","String","","on change")]), C("Dock 2", [sig("Receipt","material_movement","String","","on change")]) ]), L("Storage", [ C("Rack A", [sig("On Hand","inventory","Int","cases","periodic"), sig("Reorder Point","buffer","Int","cases","on change")]), C("Rack B", [sig("On Hand","inventory","Int","cases","periodic")]), C("Rack C", [sig("On Hand","inventory","Int","cases","periodic")]) ]), L("Shipping", [ C("Dock 4", [sig("Shipment","shipment","String","","on change"), sig("Demand Forecast","demand","Int","cases","periodic")]), C("Dock 5", [sig("Shipment","shipment","String","","on change")]) ]) ]), A("Utilities", [ L("Plant Utilities", [ C("Boiler", _sx(eqB(), [sig("Steam Pressure","process","Float","bar","periodic")])), C("Chiller", _sx(eqB(), [sig("Supply Temp","process","Float","degC","periodic")])), C("Air Compressor", _sx(eqB(), [sig("Discharge Pressure","process","Float","bar","periodic")])) ]) ]) ]) ]) }, { id:"auto", name:"Automotive tier supplier", mode:"Discrete", industry:"Automotive", conv:{ casing:"kebab", target:"sparkplug", includeEnterprise:true }, blurb:"Stamping, body shop, assembly, and logistics, with serialized safety critical parts and station torque traceability.", notes:["Serialized units carry genealogy, rolled up to lots without double counting","Sparkplug suits the high node count and birth and death awareness","Work centers and stations are structure, the part number is payload"], build:()=> node("enterprise","Tier1 Auto",[ node("site","Sandusky",[ A("Stamping", Ln("Press Line",2, i=>[ C("Destacker", _sx(eqB(), [sig("Coil Lot","lot","String","","on change")])), C("Press 400T", _sx(eqB(), [sig("Stroke Count","operation","Int","count","on change"), sig("Tonnage","process","Float","tonne","periodic"), sig("Die Id","equipment","String","","on change")])), C("Blank Loader", _sx(eqB(), [sig("Feed","material_movement","Float","m/min","periodic")])) ])), A("Body Shop", Ln("Weld Line",2, i=>[ C("Robot 12", _sx(eqB(), [sig("Weld Current","operation","Float","A","periodic"), sig("Cycle Count","operation","Int","count","on change")])), C("Robot 13", _sx(eqB(), [sig("Weld Current","operation","Float","A","periodic"), sig("Spatter","quality_test","Float","pct","periodic")])), C("Robot 14", _sx(eqB(), [sig("Weld Current","operation","Float","A","periodic")])), C("Inspection", _sx([sig("Serial","serialized_unit","String","","on change")], qaS(), rejS())) ])), A("Paint", [ L("Paint Line", [ C("E-Coat", _sx(eqB(), [sig("Bath Temp","process","Float","degC","periodic")])), C("Topcoat", _sx(eqB(), [sig("Film Build","quality_test","Float","um","periodic")])), C("Cure Oven", _sx(eqB(), [sig("Zone Temp","process","Float","degC","periodic")])) ]) ]), A("Assembly", Ln("Final Assembly",2, i=>[ C("Station 5", _sx(eqB(), [sig("Torque","quality_test","Float","Nm","periodic"), sig("Part Number","product","String","","on change"), sig("Build Serial","serialized_unit","String","","on change")], oprS())), C("Station 6", _sx(eqB(), [sig("Torque","quality_test","Float","Nm","periodic"), sig("Bill Of Materials","bom","String","","on change")])), C("Station 7", _sx(eqB(), [sig("Torque","quality_test","Float","Nm","periodic")])), C("End Of Line", _sx([sig("Pass","quality_test","Boolean","","on change"), sig("Ship Order","order","String","","on change")])) ])), A("Logistics", [ L("Dock", [ C("Sequencing", [sig("Sequence Demand","demand","Int","units","periodic")]), C("Shipping", [sig("Shipment","shipment","String","","on change")]) ]) ]) ]) ]) }, { id:"pharma", name:"Pharmaceutical batch", mode:"Batch", industry:"Pharma", conv:{ casing:"snake", target:"mqtt", includeEnterprise:true }, blurb:"Dispensing through serialization, with a QC lab and release status carried explicitly through the batch record.", notes:["The batch names both the material and the run, so genealogy must keep them apart","Release status is a specification state, not the same as on hand","Serialization adds unit identity over the lot for DSCSA"], build:()=> node("enterprise","PharmaCo",[ node("site","Cork",[ A("Dispensing", [ L("Weigh Booth", [ C("Scale 1", [sig("Material","material_definition","String","","on change"), sig("Dispensed Weight","quality_test","Float","kg","on change"), sig("Batch Id","lot","String","","on change")]), C("Scale 2", [sig("Material","material_definition","String","","on change"), sig("Dispensed Weight","quality_test","Float","kg","on change")]) ]) ]), A("OSD Suite", _sx( L("Granulation", [ C("Granulator", _sx(eqB(), [sig("Batch Id","lot","String","","on change"), sig("Phase","process_segment","String","","on change"), sig("Bed Temp","process","Float","degC","periodic"), sig("Spray Rate","operation","Float","g/min","periodic"), sig("Recipe Id","recipe","String","","on change")])), C("Fluid Bed Dryer", _sx(eqB(), [sig("Exhaust Temp","process","Float","degC","periodic"), sig("LOD","quality_test","Float","pct","on change")])) ]), Ln("Compression",2, i=>[ C("Press", _sx(eqB(), [sig("Tablet Weight","quality_test","Float","mg","periodic"), sig("Hardness","quality_test","Float","N","periodic"), sig("Speed","operation","Float","tablets/min","periodic"), sig("Release Status","specification","String","","on change")])) ]), L("Coating", [ C("Coater", _sx(eqB(), [sig("Inlet Temp","process","Float","degC","periodic"), sig("Weight Gain","quality_test","Float","pct","periodic")])) ]) )), A("Quality Control", [ L("Lab", [ C("HPLC", [sig("Assay Result","quality_test","Float","pct","on change"), sig("Sample","sublot","String","","on change"), sig("Out Of Spec","nonconformance","String","","on change")]), C("Dissolution", [sig("Dissolution","quality_test","Float","pct","on change")]), C("Balance", [sig("Mass","quality_test","Float","mg","on change")]) ]) ]), A("Packaging", [ L("Serialization", [ C("Bottle Line", _sx(eqB(), [sig("Serial","serialized_unit","String","","on change"), sig("Aggregation","transaction","String","","on change")])), C("Cartoner", _sx(eqB(), [sig("Carton Serial","serialized_unit","String","","on change")])), C("Case Packer", [sig("Ship Order","order","String","","on change"), sig("Shipment","shipment","String","","on change")]) ]) ]) ]) ]) }, { id:"chem", name:"Chemical continuous", mode:"Continuous", industry:"Chemicals", conv:{ casing:"kebab", target:"mqtt", includeEnterprise:true }, blurb:"Reaction, separation, storage, utilities, and loadout. No natural lot, so lots are imposed by tank and time.", notes:["Continuous flow has no natural lot, the nominal lot carries the boundary rule","Hazard class rides on the material and the storage location","Most signals are periodic process readings, not discrete events"], build:()=> node("enterprise","ChemWorks",[ node("site","Geismar",[ A("Reaction", Ln("Train",2, i=>[ C("Reactor", [sig("Flow","material_movement","Float","kg/h","periodic"), sig("Pressure","process","Float","bar","periodic"), sig("Temperature","process","Float","degC","periodic"), sig("Catalyst Feed","operation","Float","kg/h","periodic")]), C("Heat Exchanger", [sig("Inlet Temp","process","Float","degC","periodic"), sig("Outlet Temp","process","Float","degC","periodic")]), C("Feed Pump", _sx(eqB(), [sig("Discharge Pressure","process","Float","bar","periodic")])) ])), A("Separation", [ L("Distillation", [ C("Column C1", [sig("Reflux Ratio","operation","Float","","periodic"), sig("Top Temp","process","Float","degC","periodic"), sig("Purity","quality_test","Float","pct","periodic")]), C("Column C2", [sig("Top Temp","process","Float","degC","periodic"), sig("Purity","quality_test","Float","pct","periodic")]) ]) ]), A("Storage", [ L("Tank Farm", [ C("Tank 7", [sig("Level","inventory","Float","pct","periodic"), sig("Nominal Lot","lot","String","","on change"), sig("Hazard Class","specification","String","","on change")]), C("Tank 8", [sig("Level","inventory","Float","pct","periodic"), sig("Temperature","process","Float","degC","periodic")]), C("Tank 9", [sig("Level","inventory","Float","pct","periodic")]) ]) ]), A("Utilities", [ L("Steam", [ C("Boiler", _sx(eqB(), [sig("Steam Flow","material_movement","Float","t/h","periodic"), sig("Fuel Gas","process","Float","Nm3/h","periodic")])) ]), L("Cooling", [ C("Cooling Tower", _sx(eqB(), [sig("Basin Temp","process","Float","degC","periodic")])) ]) ]), A("Loadout", [ L("Rail", [ C("Loading Arm", [sig("Loaded Mass","material_movement","Float","kg","on change"), sig("Shipment","shipment","String","","on change"), sig("Order","order","String","","on change")]) ]), L("Truck", [ C("Truck Scale", [sig("Net Weight","material_movement","Float","kg","on change")]) ]) ]) ]) ]) }, { id:"cpg", name:"Multi-site CPG", mode:"Discrete", industry:"Consumer goods", conv:{ casing:"kebab", target:"mqtt", includeEnterprise:true }, blurb:"One enterprise, two sites running the same processing and packaging layout. Shows how the namespace scales across plants.", notes:["The same structure repeats per site, so wildcard subscriptions work across plants","Site is the natural group boundary for federation","Identical signal names across sites make fleet dashboards trivial"], build:()=> node("enterprise","Global CPG",[ _cpgSite("Memphis"), _cpgSite("Rotterdam") ]) }, { id:"meddev", name:"Medical device", mode:"Discrete", industry:"Medical devices", conv:{ casing:"kebab", target:"mqtt", includeEnterprise:true }, blurb:"Molding, cleanroom assembly, sterilization, and packaging, with device identity split into device and production identifiers.", notes:["UDI splits into a device identifier (the class) and production identifiers (the instance)","Expiry and lot are production identifiers carried in payload","Final test results and nonconformances attach at the test cell"], build:()=> node("enterprise","MedDev Inc",[ node("site","Galway",[ A("Molding", Ln("Injection",2, i=>[ C("Press", _sx(eqB(), [sig("Cycle Time","operation","Float","s","periodic"), sig("Cavity Pressure","process","Float","bar","periodic"), sig("Material Lot","lot","String","","on change")])), C("Takeout Robot", _sx(eqB(), [sig("Cycle Count","operation","Int","count","on change")])) ])), A("Cleanroom", _sx( Ln("Assembly",2, i=>[ C("Station", _sx(eqB(), [sig("Device Identifier","product","String","","on change"), sig("Serial","serialized_unit","String","","on change"), sig("Expiry","specification","DateTime","","on change")], oprS())) ]), L("Final Test", [ C("Tester", [sig("Leak Test","quality_test","Float","kPa","periodic"), sig("Result","quality_test","String","","on change"), sig("Nonconformance","nonconformance","String","","on change")]), C("Vision", [sig("Pass","quality_test","Boolean","","on change")]) ]) )), A("Sterilization", [ L("EO Chamber", [ C("Sterilizer", [sig("Cycle Id","lot","String","","on change"), sig("Dose","process","Float","kGy","periodic"), sig("Release Status","specification","String","","on change")]) ]), L("Aeration", [ C("Aeration Cell", [sig("Residual","quality_test","Float","ppm","on change")]) ]) ]), A("Packaging", [ L("Pack", [ C("Sealer", _sx(eqB(), [sig("Seal Verify","quality_test","Boolean","","on change")])), C("Labeler", [sig("Label Verify","quality_test","Boolean","","on change"), sig("Shipment","shipment","String","","on change")]) ]) ]) ]) ]) }, { id:"semi", name:"Semiconductor fab", mode:"Discrete", industry:"Semiconductor", conv:{ casing:"kebab", target:"mqtt", includeEnterprise:true }, blurb:"Diffusion, lithography, etch, and metrology, with recipe driven tools and wafer lot tracking through an automated stocker.", notes:["Wafer lots and boat slots are the trace units, carried as signals not levels","Process tools are recipe driven, so the recipe id rides with every run","Metrology and defect data attach at the inspection cell, not the process tool"], build:()=> node("enterprise","FabCo",[ node("site","Hillsboro",[ A("Diffusion", [ L("Furnace Bank", [ C("Furnace 1", [sig("Temperature","process","Float","degC","periodic"), sig("Recipe","recipe","String","","on change"), sig("Wafer Lot","lot","String","","on change"), sig("Boat Slot","sublot","String","","on change")]), C("Furnace 2", [sig("Temperature","process","Float","degC","periodic"), sig("Recipe","recipe","String","","on change"), sig("Wafer Lot","lot","String","","on change")]), C("Furnace 3", [sig("Temperature","process","Float","degC","periodic"), sig("Recipe","recipe","String","","on change")]) ]) ]), A("Photolithography", Ln("Litho Cell",2, i=>[ C("Scanner", _sx(eqB(), [sig("Overlay","quality_test","Float","nm","periodic"), sig("Exposure Dose","operation","Float","mJ","periodic"), sig("Reticle","equipment","String","","on change")])), C("Track", [sig("Resist Thickness","quality_test","Float","nm","periodic"), sig("Bake Temp","process","Float","degC","periodic")]) ])), A("Etch", Ln("Plasma Etch",2, i=>[ C("Etcher", _sx(eqB(), [sig("RF Power","operation","Float","W","periodic"), sig("Chamber Pressure","process","Float","mTorr","periodic"), sig("Endpoint","event","String","","on change")])) ])), A("Metrology", [ L("Inspection", [ C("CD SEM", [sig("Critical Dimension","quality_test","Float","nm","on change"), sig("Wafer Id","serialized_unit","String","","on change")]), C("Overlay Tool", [sig("Overlay","quality_test","Float","nm","on change")]), C("Defect Scan", [sig("Defect Count","nonconformance","Int","count","on change")]) ]) ]), A("Logistics", [ L("Stocker", [ C("AMHS", [sig("Move","material_movement","String","","on change"), sig("WIP","inventory","Int","lots","periodic")]), C("Sorter", [sig("Sort","material_movement","String","","on change")]) ]) ]) ]) ]) }, { id:"battery", name:"Battery cell manufacturing", mode:"Mixed", industry:"Battery", conv:{ casing:"kebab", target:"mqtt", includeEnterprise:true }, blurb:"Electrode coating, cell assembly, and formation, spanning a continuous front end and a discrete back end.", notes:["Coating is continuous, cell assembly is discrete, so the namespace spans both modes","Cell serial appears at winding and follows the unit through formation","Grade from formation is a specification state used to bin cells"], build:()=> node("enterprise","CellWorks",[ node("site","Reno",[ A("Electrode", Ln("Coating",2, i=>[ C("Coater", _sx(eqB(), [sig("Line Speed","operation","Float","m/min","periodic"), sig("Coat Weight","quality_test","Float","g/m2","periodic"), sig("Slurry Lot","lot","String","","on change"), sig("Oven Temp","process","Float","degC","periodic")])), C("Calender", [sig("Thickness","quality_test","Float","um","periodic"), sig("Roll Force","process","Float","kN","periodic")]), C("Slitter", _sx(eqB(), [sig("Width","quality_test","Float","mm","periodic")])) ])), A("Cell Assembly", _sx( Ln("Winding",2, i=>[ C("Winder", _sx(eqB(), [sig("Cell Serial","serialized_unit","String","","on change"), sig("Tension","operation","Float","N","periodic"), sig("Defect","nonconformance","String","","on change")])) ]), L("Filling", [ C("Electrolyte Fill", [sig("Fill Mass","quality_test","Float","g","on change"), sig("Vacuum","process","Float","mbar","periodic")]), C("Sealer", _sx(eqB(), [sig("Seal Check","quality_test","Boolean","","on change")])) ]) )), A("Formation", [ L("Aging", [ C("Formation Rack 1", [sig("Charge Current","operation","Float","A","periodic"), sig("Capacity","quality_test","Float","Ah","on change"), sig("Grade","specification","String","","on change")]), C("Formation Rack 2", [sig("Charge Current","operation","Float","A","periodic"), sig("Capacity","quality_test","Float","Ah","on change")]), C("Formation Rack 3", [sig("Charge Current","operation","Float","A","periodic")]) ]) ]), A("Logistics", [ L("Warehouse", [ C("Pack Out", [sig("Module Id","product","String","","on change"), sig("Shipment","shipment","String","","on change"), sig("WIP","inventory","Int","cells","periodic")]) ]) ]) ]) ]) }, { id:"electronics", name:"Electronics PCB assembly", mode:"Discrete", industry:"Electronics", conv:{ casing:"kebab", target:"mqtt", includeEnterprise:true }, blurb:"Surface mount and through hole lines into functional test and packout, with boards serialized early and tracked throughout.", notes:["Panels and boards are serialized early and tracked through every cell","Feeder lots tie placement defects back to a component reel","High node count per line suits Sparkplug if you switch the target"], build:()=> node("enterprise","BoardWorks",[ node("site","Penang",[ A("SMT", Ln("SMT Line",3, i=>[ C("Printer", _sx(eqB(), [sig("Paste Volume","quality_test","Float","pct","periodic"), sig("Panel Id","serialized_unit","String","","on change"), sig("Stencil","equipment","String","","on change")])), C("Placer P1", _sx(eqB(), [sig("Placement Rate","operation","Float","cph","periodic"), sig("Pickup Errors","nonconformance","Int","count","on change"), sig("Feeder Lot","lot","String","","on change")])), C("Placer P2", _sx(eqB(), [sig("Placement Rate","operation","Float","cph","periodic"), sig("Feeder Lot","lot","String","","on change")])), C("Reflow Oven", [sig("Zone Temp","process","Float","degC","periodic"), sig("Conveyor Speed","operation","Float","cm/min","periodic")]), C("AOI", _sx(qaS(), rejS())) ])), A("Through Hole", [ L("Wave Solder", [ C("Wave", [sig("Solder Temp","process","Float","degC","periodic"), sig("Board Id","serialized_unit","String","","on change")]), C("Selective", [sig("Pot Temp","process","Float","degC","periodic")]) ]) ]), A("Test", [ L("Functional Test", [ C("ICT", [sig("Test Result","quality_test","String","","on change"), sig("Bill Of Materials","bom","String","","on change")]), C("FCT", [sig("Pass","quality_test","Boolean","","on change")]), C("Burn In", [sig("Chamber Temp","process","Float","degC","periodic")]) ]) ]), A("Packout", [ L("Pack", [ C("Boxing", [sig("Product","product","String","","on change"), sig("Shipment","shipment","String","","on change"), sig("Order","order","String","","on change")]) ]) ]) ]) ]) }, { id:"steel", name:"Steel and metals", mode:"Continuous", industry:"Metals", conv:{ casing:"kebab", target:"mqtt", includeEnterprise:true }, blurb:"Melt shop, continuous caster, and hot strip mill, where the heat becomes slabs and then coils as the material transforms.", notes:["Heat id from the melt shop is the lot, carried through casting and rolling","Slab and coil ids are serialized units created as material transforms","Most signals are periodic process readings on a continuous line"], build:()=> node("enterprise","SteelCo",[ node("site","Gary",[ A("Melt Shop", [ L("EAF", [ C("Furnace A", [sig("Tap Temp","process","Float","degC","periodic"), sig("Power Input","operation","Float","MW","periodic"), sig("Heat Id","lot","String","","on change")]), C("Furnace B", [sig("Tap Temp","process","Float","degC","periodic"), sig("Heat Id","lot","String","","on change")]), C("Ladle Furnace", [sig("Composition","quality_test","String","","on change"), sig("Grade","specification","String","","on change")]) ]) ]), A("Casting", Ln("Caster",2, i=>[ C("Mold", [sig("Cast Speed","operation","Float","m/min","periodic"), sig("Mold Level","process","Float","pct","periodic"), sig("Slab Id","serialized_unit","String","","on change")]), C("Torch Cut", _sx(eqB(), [sig("Cut Length","quality_test","Float","mm","on change")])) ])), A("Rolling", [ L("Hot Strip Mill", [ C("Roughing Stand", [sig("Roll Force","process","Float","MN","periodic"), sig("Strip Temp","process","Float","degC","periodic")]), C("Finishing Stand", [sig("Gauge","quality_test","Float","mm","periodic"), sig("Width","quality_test","Float","mm","periodic")]), C("Coiler", [sig("Coil Id","serialized_unit","String","","on change"), sig("Coil Temp","process","Float","degC","periodic")]) ]) ]), A("Shipping", [ L("Yard", [ C("Crane", [sig("Coil Move","material_movement","String","","on change"), sig("Shipment","shipment","String","","on change"), sig("Order","order","String","","on change")]) ]) ]) ]) ]) }, { id:"brewing", name:"Brewing and beverage", mode:"Batch", industry:"Beverage", conv:{ casing:"kebab", target:"mqtt", includeEnterprise:true }, blurb:"Brewhouse and fermentation feeding a canning line and cold store, a batch front end with a discrete, lot coded back end.", notes:["Brew id is the batch, fermentation is tracked by vessel and phase","Packaging is discrete and lot coded, fed from a batch upstream","Recipe drives the brewhouse, quality checks gate each step"], build:()=> node("enterprise","BrewCo",[ node("site","Asheville",[ A("Brewhouse", [ L("Brew Line", [ C("Mash Tun", [sig("Mash Temp","process","Float","degC","periodic"), sig("Recipe","recipe","String","","on change"), sig("Brew Id","lot","String","","on change")]), C("Lauter Tun", [sig("Runoff Rate","operation","Float","hL/h","periodic")]), C("Kettle", [sig("Boil Temp","process","Float","degC","periodic"), sig("Gravity","quality_test","Float","SG","on change")]), C("Whirlpool", [sig("Rest Time","process_segment","Float","min","on change")]) ]) ]), A("Fermentation", [ L("Cellar", [ C("FV 12", [sig("Temperature","process","Float","degC","periodic"), sig("Pressure","process","Float","bar","periodic"), sig("Phase","process_segment","String","","on change")]), C("FV 13", [sig("Temperature","process","Float","degC","periodic"), sig("Phase","process_segment","String","","on change")]), C("FV 14", [sig("Temperature","process","Float","degC","periodic")]) ]) ]), A("Packaging", Ln("Canning",2, i=>[ C("Filler", _sx(eqB(), opR("cans/min"), [sig("Fill Level","quality_test","Float","mL","periodic")], lotS("Lot"))), C("Seamer", _sx([sig("Seam Check","quality_test","Boolean","","on change")], rejS())), C("Pasteurizer", [sig("PU","process","Float","PU","periodic")]), C("Palletizer", [sig("Cases Out","material_movement","Int","cases","on change"), sig("Pallet","shipment","String","","on change")]) ])), A("Warehouse", [ L("Cold Store", [ C("Storage", [sig("On Hand","inventory","Int","cases","periodic"), sig("Demand","demand","Int","cases","periodic")]) ]) ]) ]) ]) }, { id:"plastics", name:"Plastics injection molding", mode:"Discrete", industry:"Plastics", conv:{ casing:"kebab", target:"mqtt", includeEnterprise:true }, blurb:"A press bank with resin drying and downstream finishing, where a fast cycle makes shot count and cycle time the heartbeat.", notes:["Each press runs a fast cycle, so shot count and cycle time are the heartbeat","Resin lot ties scrap and defects back to the material","Mold id is equipment identity, the part number is the product"], build:()=> node("enterprise","PolyForm",[ node("site","Toledo",[ A("Molding", [ L("Press Bank", [ C("Press 11", _sx(eqB(), [sig("Cycle Time","operation","Float","s","periodic"), sig("Cavity Pressure","process","Float","bar","periodic"), sig("Melt Temp","process","Float","degC","periodic"), sig("Mold Id","equipment","String","","on change"), sig("Shot Count","operation","Int","count","on change")])), C("Press 12", _sx(eqB(), [sig("Cycle Time","operation","Float","s","periodic"), sig("Resin Lot","lot","String","","on change"), sig("Shot Count","operation","Int","count","on change")])), C("Press 13", _sx(eqB(), [sig("Cycle Time","operation","Float","s","periodic"), sig("Scrap","nonconformance","Int","count","on change")])), C("Press 14", _sx(eqB(), [sig("Cycle Time","operation","Float","s","periodic")])) ] ) ]), A("Material", [ L("Drying", [ C("Dryer", [sig("Dew Point","process","Float","degC","periodic"), sig("Resin","material_definition","String","","on change")]), C("Blender", [sig("Regrind Ratio","operation","Float","pct","periodic")]) ]) ]), A("Finishing", Ln("Assembly",2, i=>[ C("Trimmer", [sig("Part Number","product","String","","on change"), sig("Dimension","quality_test","Float","mm","periodic")]), C("Pad Print", [sig("Print Verify","quality_test","Boolean","","on change")]), C("Assembly Cell", _sx(eqB(), opR("parts/min"))) ])), A("Shipping", [ L("Dock", [ C("Pack Out", [sig("Cartons","material_movement","Int","cartons","on change"), sig("Shipment","shipment","String","","on change"), sig("Order","order","String","","on change")]) ]) ]) ]) ]) } ]; function loadScenario(id){ const scn = SCENARIOS.find(s => s.id === id); if(!scn) return; state.tree = reidTree(scn.build()); state.convention = Object.assign({ separator:"/", casing:"kebab", target:"mqtt", includeEnterprise:true, levelProfile:"discrete" }, scn.conv || {}); state.selectedId = firstSelectableNode(state.tree).id; state.sigEdit = null; state.loaderOpen = false; state.focusEditor = false; if(storageOk()) createProject(scn.name); state.consumers = null; state.context = null; clearHistory(); toast("Loaded " + scn.name + ". Your Library now reflects this model. Refine the entities, set the convention, then build the namespace."); goTab("entities"); } /* =================================================================== VALIDATE: anti-pattern engine =================================================================== */ function sevLevel(s){ return s==="high"?3:s==="med"?2:1; } function sevWord(s){ return s==="high"?"high":s==="med"?"medium":"low"; } function sevMeter(s){ const lv = sevLevel(s); let m = ""; for(let i=1;i<=3;i++) m += ''; return '' + m + '' + sevWord(s) + ''; } function nameShape(s){ s = String(s); return (/\s/.test(s)?"S":"") + (/-/.test(s)?"-":"") + (/_/.test(s)?"_":"") + (/[a-z][A-Z]/.test(s)?"U":""); } const PLC_RE = /(\bdb\d+\b|dbw\d+|\bplc\b|%[iqm]\d|\.[a-z]{1,3}\d)/i; const ID_LEVEL_RE = /\b(lot|sn|serial|batch|order|wo)[-_ ]?\d+/i; const UNIT_RE = /(degc|degf|psi|bar|mm|kg|kpa|rpm|pct|percent|amps?|volts?|nm|ma)\b/i; function runDesignChecks(){ const sigs = collectSignals(); const mqtt = state.convention.target === "mqtt"; const checks = []; const C = (id,label,sev,applicable,items,why,fix,passNote) => { const fail = applicable && items && items.length; checks.push({ id:id, label:label, sev:sev, applicable:applicable!==false, pass: !fail, count: fail?items.length:0, where: fail ? items.slice(0,4).join(", ") : (passNote||"clear"), why:why, fix:fix }); }; // empty names let empties = []; walk(state.tree, n => { if(!String(n.name||"").trim()) empties.push("a " + levelLabel(n.level)); (n.signals||[]).forEach(s => { if(!String(s.name||"").trim()) empties.push("a signal on " + n.name); }); }); C("empty","Every node and signal is named","high",true,empties,"A blank name produces an empty or broken path segment.","Give every node and signal a clear name.","all named"); // illegal chars let illegal = []; walk(state.tree, n => { if(/[#+\/]/.test(n.name)) illegal.push("node " + n.name); (n.signals||[]).forEach(s => { if(/[#+\/]/.test(s.name)) illegal.push("signal " + s.name); }); }); C("illegal","No illegal characters in names","high",true,illegal,"The hash, plus, and slash are MQTT control characters. A name that contains them produces an invalid or misrouted topic.","Remove these characters. The tool strips them on output, but the source name should be clean so the standard reads true.","no control characters"); // spaces with raw casing let spaced = []; if(state.convention.casing==="raw"){ walk(state.tree, n => { if(/\s/.test(n.name)) spaced.push(n.name); (n.signals||[]).forEach(s => { if(/\s/.test(s.name)) spaced.push(s.name); }); }); } C("spaces","No spaces under as-entered casing","med",state.convention.casing==="raw",spaced,"Spaces are legal in MQTT but they break tooling, shells, and dashboards constantly.","Switch the casing to kebab or snake on the Convention tab, or remove the spaces.","no spaces"); // branch publishing let branchPub = []; walk(state.tree, n => { if((n.children||[]).length && (n.signals||[]).length) branchPub.push(n.name); }); C("branch","Data lives on leaf nodes only","med",true,branchPub,"These nodes have children and also carry signals. Data on a branch makes subscriptions ambiguous and breaks the leaf is data rule.","Move the signals to a leaf node below, or split the branch so data lives at the bottom of the tree.","data is on leaves"); // identifier as level let idLevels = []; walk(state.tree, n => { if(n.level!=="enterprise" && (ID_LEVEL_RE.test(n.name) || /^\d+$/.test(n.name.trim()))) idLevels.push(n.name); }); C("idlevel","No identifier used as a structural level","high",true,idLevels,"A lot, serial, or order number as a topic level explodes the namespace and ties structure to data that changes constantly.","Keep the identifier in the payload as a signal. The structure should be stable equipment, not moving identities.","structure is stable"); // PLC leak let plc = []; walk(state.tree, n => { if(PLC_RE.test(n.name)) plc.push("node " + n.name); (n.signals||[]).forEach(s => { if(PLC_RE.test(s.name)) plc.push("signal " + s.name); }); }); C("plc","No vendor or controller naming leaking in","high",true,plc,"Names like DB1.DBW0 tie the namespace to how a thing is wired, not what it is. The next PLC change breaks every subscriber.","Name by what the signal means. Map the controller address to the semantic name at the edge, once.","names are semantic"); // value in name let valName = []; sigs.forEach(({sig:s}) => { if(/(^|[^a-z])(true|false)$/i.test(s.name) || /\d+(\.\d+)?$/.test(s.name.trim())) valName.push(s.name); }); C("valname","No value encoded in a signal name","med",true,valName,"The name should identify the thing. The reading belongs in the payload, otherwise every value becomes a new topic.","Name the measurement, for example temperature, and let the payload carry the number.","names are not values"); // units in name let unitName = []; sigs.forEach(({sig:s}) => { if(UNIT_RE.test(s.name)) unitName.push(s.name); }); C("unitname","Units are metadata, not in the name","low",true,unitName,"Units in the name fix the unit forever and clutter the path. Units are metadata.","Drop the unit from the name and set the units field on the signal instead.","units kept as metadata"); // missing units on numeric let noUnits = []; sigs.forEach(({sig:s}) => { if((s.dtype==="Float"||s.dtype==="Int") && !s.units) noUnits.push(s.name); }); C("nounits","Numeric signals carry units","low",true,noUnits,"A number without a unit is ambiguous to every consumer downstream.","Set the units field on each numeric signal, even if it is just count or percent.","numerics have units"); // name vs entity mismatch const HINTS = [ { re:/(temperature|\btemp\b|pressure|\bflow\b|\blevel\b|speed|\brpm\b|vibration|current|voltage|torque)/i, ok:["process","operation","equipment","quality_test"] }, { re:/(\blot\b|\bbatch\b)/i, ok:["lot","sublot"] }, { re:/(serial|\bsn\b)/i, ok:["serialized_unit"] }, { re:/(reject|defect|scrap)/i, ok:["nonconformance","quality_test","operation"] }, { re:/(\border\b|\bwo\b|work order)/i, ok:["order","operation"] }, { re:/(inventory|on hand|stock)/i, ok:["inventory","buffer"] } ]; let mism = []; sigs.forEach(({sig:s}) => { for(const hnt of HINTS){ if(hnt.re.test(s.name) && hnt.ok.indexOf(s.entity)===-1){ mism.push(s.name + " typed as " + entityLabel(s.entity)); break; } } }); C("entmatch","Signal names match their entity type","low",true,mism,"A name that implies one thing typed as another confuses every consumer that filters by entity.","Retype the signal to the entity its name implies, or rename it.","names match entities"); // duplicate topics (mqtt) const seen = {}; let dups = []; sigs.forEach(({node:n, sig:s}) => { const t = signalTopicMqtt(n.id,s); if(seen[t]) dups.push(t); else seen[t]=1; }); C("duptopic","Every topic is unique","high",mqtt,dups,"Duplicate topics mean two sources fight over one address. One silently overwrites the other.","Rename one of the signals, or move it so its path is unique.","topics are unique"); // sparkplug metric collision let spColl = []; if(!mqtt){ const sseen={}; sparkplugDevices().forEach(d => d.metrics.forEach(m => { const k = d.topic + " :: " + m.name; if(sseen[k]) spColl.push(d.device || d.edge); else sseen[k]=1; })); } C("spcollide","No Sparkplug metric collisions","high",!mqtt,spColl,"Two metrics with the same name on one device node overwrite each other in the payload.","Give each metric a unique name on its device, or split the device.","metrics are unique per device"); // depth deep let maxDepth = 0; walk(state.tree, (n,p,a) => { maxDepth = Math.max(maxDepth, a.length); }); C("deep","Depth is reasonable","low",true,(maxDepth>7?[maxDepth + " levels"]:[]),"Paths beyond about seven levels get hard to read and to subscribe to.","Collapse a level if two levels always move together.","depth is manageable"); // flat C("flat","The namespace carries context","low",(sigs.length>6),((maxDepth<=2 && sigs.length>6)?[maxDepth + " levels, " + sigs.length + " signals"]:[]),"A flat namespace loses the equipment context that makes a UNS useful.","Introduce site, area, line, and cell so signals carry their place in the plant.","levels carry context"); // sibling casing let mixed = []; walk(state.tree, n => { const kids=(n.children||[]); if(kids.length>1){ const shapes={}; kids.forEach(k=>shapes[nameShape(k.name)]=1); if(Object.keys(shapes).length>1) mixed.push(n.name); } }); C("siblingcase","Siblings are named consistently","low",true,mixed,"Children of the same parent are named in different styles. Consistency is what lets people predict a path.","Pick one style for siblings. The casing rule handles most of this if names are entered cleanly.","siblings are consistent"); // governance owner + version C("govowner","The standard has a named owner","low",true,(state.governance.owner?[]:["Governance tab"]),"A namespace without an owner drifts. Someone has to hold the line on the convention.","Name an owner on the Governance tab. It flows into the exported standard.","owner is set"); C("govver","The namespace has a version","low",true,(state.governance.version?[]:["Governance tab"]),"Without a version you cannot tell which rules a topic was built under.","Set a version on the Governance tab.","version is set"); return checks; } function designChecks(){ return runDesignChecks().filter(c => c.applicable !== false); } function validateDesign(){ return runDesignChecks().filter(c => !c.pass).map(c => ({ sev:c.sev, title:c.label.replace(/^No /,"").replace(/^Every /,"").replace(/^The /,""), where:c.where, why:c.why, fix:c.fix })).sort((a,b) => sevLevel(b.sev) - sevLevel(a.sev)); } function sevWord(s){ return s==="high"?"high":s==="med"?"medium":"low"; } function checksSummaryHtml(){ const checks = designChecks(); const passed = checks.filter(c=>c.pass).length, total = checks.length; let h = '
' + passed + ' / ' + total + '
Passed ' + passed + ' of ' + total + ' checks
Heuristic structural checks on this design. The failures below are ranked by impact.
'; h += '
Show every check, passed and failed
'; checks.forEach(c => { h += '
' + (c.pass?"[ pass ]":"[ fix ]") + '' + esc(c.label) + ''; if(!c.pass) h += '' + esc(sevWord(c.sev)) + ''; h += '' + esc(c.pass ? c.where : (c.count + " to fix: " + c.where)) + '
'; }); h += '
'; return h; } function validateTopicList(text){ const f = []; const add = (sev,title,where,why,fix) => f.push({ sev:sev, title:title, where:where, why:why, fix:fix }); const lines = String(text||"").split(/\r?\n/).map(s=>s.trim()).filter(Boolean); if(!lines.length) return f; let illegal=[], spaces=[], depths={}, valEnd=[], seen={}, dups=[], shapes={}; lines.forEach(t => { const body = t.replace(/\/#$/,"").replace(/\/\+/g,"/"); if(/[#+]/.test(body)) illegal.push(t); if(/\s/.test(t)) spaces.push(t); const segs = t.split("/").filter(Boolean); depths[segs.length] = (depths[segs.length]||0)+1; const last = segs[segs.length-1]||""; if(/\d+(\.\d+)?$/.test(last) || /^(true|false)$/i.test(last)) valEnd.push(t); if(seen[t]) dups.push(t); else seen[t]=1; segs.forEach(s => shapes[nameShape(s)] = 1); }); if(illegal.length) add("high","Wildcard characters inside a topic", illegal.slice(0,4).join(", "), "The hash and plus are subscription wildcards. Inside a published topic they are illegal.", "Remove them from published topics. They belong only in subscriptions."); if(spaces.length) add("med","Spaces in a topic", spaces.slice(0,4).join(", "), "Spaces break tooling and are a common source of silent mismatches.", "Replace spaces with a casing convention such as kebab or snake."); if(valEnd.length) add("med","A value at the end of a topic", valEnd.slice(0,4).join(", "), "A number or boolean as the last segment usually means a value was put in the path.", "Move the value to the payload and name the measurement instead."); if(dups.length) add("high","Duplicate topics", Array.from(new Set(dups)).slice(0,4).join(", "), "The same topic published by two sources means one overwrites the other.", "Make each published topic unique."); const depthKeys = Object.keys(depths).map(Number); if(depthKeys.length > 3 && (Math.max.apply(null,depthKeys) - Math.min.apply(null,depthKeys)) > 3) add("low","Very uneven depth", "depths " + depthKeys.sort((a,b)=>a-b).join(", "), "Topics at wildly different depths suggest no agreed structure.", "Agree a level model, site to cell, and place every topic within it."); if(Object.keys(shapes).length > 2) add("low","Mixed casing across the list", Object.keys(shapes).length + " distinct styles", "Several naming styles in one namespace make paths unpredictable.", "Normalize to one casing convention."); if(!f.length) add("low","No obvious problems found", String(lines.length) + " topics checked", "The quick checks passed. This is a heuristic pass, not a full review.", "Run the design through the full structure on the Designer tab for deeper checks."); return f.sort((a,b) => sevLevel(b.sev) - sevLevel(a.sev)); } function findingCard(f){ let h = '
' + sevMeter(f.sev) + '' + esc(f.title) + ''; if(f.where) h += '' + esc(f.where) + ''; h += '

' + esc(f.why) + '

Fix' + esc(f.fix) + '

'; return h; } /* ---------- Entities and templates ---------- */ function sigSuggestHtml(entId){ const tpl = templateFor(entId); if(!tpl.length) return '
Nothing in your library for ' + esc(entityLabel(entId)) + ' yet. Type a name below, or add some on the Library tab.
'; let h = '
From your library for ' + esc(entityLabel(entId)) + ', click one to use
'; tpl.forEach((s, idx) => { h += ''; }); return h + '
'; } function groupSelect(idAttr, selected){ const gs = Object.keys(entityGroups()); if(gs.indexOf("Custom") === -1) gs.push("Custom"); const sel = selected || "Custom"; return ''; } function entListHtml(){ const q = (state.entSearch||"").trim().toLowerCase(); const filter = state.entFilter || "All"; const groups = entityGroups(); let h = '', shown = 0; Object.keys(groups).forEach(g => { if(filter!=="All" && filter!=="Custom" && filter!==g) return; groups[g].forEach(e => { if(filter==="Custom" && !(state.customEntities||[]).some(c => c.id===e.id)) return; if(q && e.label.toLowerCase().indexOf(q)===-1 && e.id.toLowerCase().indexOf(q)===-1) return; const sel = e.id === state.entSel ? " sel" : ""; const custom = (state.customEntities||[]).some(c => c.id === e.id); const n = templateFor(e.id).length; const used = instancesOf(e.id).length; h += ''; shown++; }); }); if(!shown) h += '

No entities match. Clear the search or filter, or add a new entity.

'; return h; } function entDetailHtml(){ const id = state.entSel; if(!id || !entityInfo(id)) return '

No entity selected

Select an entity on the left to see and edit the signals it publishes.

'; const e = entityInfo(id); const custom = (state.customEntities||[]).some(c => c.id === id); const tpl = templateFor(id); const inst = instancesOf(id); let h = '
'; h += '
' + esc(e.group) + '' + (custom ? 'custom' : 'default') + (templateIsCustom(id) && !custom ? 'edited' : '') + '
'; h += '

' + esc(e.label) + '

'; h += '

' + esc(id) + ' · ' + tpl.length + ' signal' + (tpl.length===1?'':'s') + ' · ' + (inst.length ? 'placed ' + inst.length + ' time' + (inst.length>1?'s':'') + ' in your tree' : 'not placed in the tree yet') + '

'; h += '
'; h += '
'; h += '

Signals it publishes

'; h += '

The signals this entity publishes. They appear as one-click options when this entity is picked while adding a signal on the Designer. These defaults are a draft to tune.

'; if(!tpl.length) h += '

No signals yet. Add the ones this entity usually publishes.

'; tpl.forEach((s, idx) => { h += '
' + esc(s.name) + '' + esc(s.dtype) + ''; if(s.units) h += '' + esc(s.units) + ''; h += '' + esc(s.update) + ''; h += '
'; }); const ed = (state.tplEdit != null && tpl[state.tplEdit]) ? tpl[state.tplEdit] : null; h += '
' + (ed ? "Edit a signal" : "Add a signal") + ''; h += '
'; h += '
'; h += ''; h += ''; h += ''; h += '
'; if(ed) h += ''; h += '
'; h += '
'; h += '
'; h += '
Where it lives in your tree' + (inst.length ? ' · ' + inst.length + ' place' + (inst.length>1?'s':'') : '') + ''; if(inst.length){ h += '

' + esc(e.label) + ' is placed in ' + inst.length + ' place' + (inst.length>1?"s":"") + ':

'; inst.slice(0,10).forEach(n => { h += '
' + esc(nodePath(n.id)) + '
'; }); h += '
'; if(inst.length>10) h += '

Showing 10 of ' + inst.length + '.

'; } else { h += '

Not placed yet. On the Designer, pick a node and use Add a signal, then choose ' + esc(e.label) + '. Its signals show up as one-click options.

'; } h += '
'; h += '
'; if(hasDefaultTemplate(id) && templateIsCustom(id)) h += ''; if(custom) h += ''; h += '
'; h += '
'; return h + '
'; } function instancesOf(entityId){ const seen={}, out=[]; walk(state.tree, (n) => { if((n.signals||[]).some(s => s.entity===entityId) && !seen[n.id]){ seen[n.id]=1; out.push(n); } }); return out; } function libraryStepsHtml(){ let h = '
'; h += '
1
Create an entity

Pick one of the UMDA entities, or add your own. An entity is a kind of thing, like equipment, a lot, or a quality test.

'; h += '
2
List its signals

List the signals it publishes, like Temperature or State. These become one-click options on the Designer.

'; h += '
3
Set the rules, then place instances

Set the naming convention next, then add nodes on the Designer and attach these signals to build the real plant.

'; return h + '
'; } function entChipsHtml(){ const groups = Object.keys(entityGroups()); const hasCustom = (state.customEntities||[]).length > 0; const opts = ["All"].concat(hasCustom?["Custom"]:[]).concat(groups); const active = state.entFilter || "All"; return opts.map(g => '').join(""); } function refreshEntList(){ const list = document.getElementById("entList"); if(!list) return; list.innerHTML = entListHtml(); list.querySelectorAll("[data-ent]").forEach(r => { const pick = () => { state.entSel = r.dataset.ent; state.tplEdit = null; refreshEntities(); const d = document.getElementById("entDetail"); if(d && d.getBoundingClientRect().top > window.innerHeight * 0.6) d.scrollIntoView({ behavior: "smooth", block: "start" }); }; r.addEventListener("click", pick); r.addEventListener("keydown", e => { if(e.key==="Enter"||e.key===" "){ e.preventDefault(); pick(); } }); }); } function renderEntities(v){ if(!state.entSel || !entityInfo(state.entSel)) state.entSel = (allEntities()[0] || {}).id || null; let h = pageIntro( "Library: define entities once, use them everywhere", "The library holds the kinds of things your plant publishes and the signals each one carries. Define an entity and its signals here, then place instances of it in the tree on the Designer. Editing here changes the definitions you draw from, it does not move anything onto the plant.", null ); h += libraryStepsHtml(); h += '
Entities
'; if(state.entAddOpen){ h += '
'; h += ''; h += ''; h += '
The id is derived from the name, for example Tool Offset becomes tool_offset.
'; h += '
'; } h += ''; h += '
' + entChipsHtml() + '
'; h += '
' + entListHtml() + '
'; h += '
' + entDetailHtml() + '
'; h += '
Reuse this library

Build a house standard once, then carry it into any other project. This is separate from a plant design.

'; v.innerHTML = h; wireEntities(v); } function refreshEntities(){ const l = document.getElementById("entList"); if(l) l.innerHTML = entListHtml(); const d = document.getElementById("entDetail"); if(d) d.innerHTML = entDetailHtml(); wireEntInner(document.getElementById("view")); } function wireEntInner(v){ if(!v) return; v.querySelectorAll("[data-ent]").forEach(r => { const pick = () => { state.entSel = r.dataset.ent; state.tplEdit = null; refreshEntities(); const d = document.getElementById("entDetail"); if(d && d.getBoundingClientRect().top > window.innerHeight * 0.6) d.scrollIntoView({ behavior: "smooth", block: "start" }); }; r.addEventListener("click", pick); r.addEventListener("keydown", e => { if(e.key==="Enter"||e.key===" "){ e.preventDefault(); pick(); } }); }); v.querySelectorAll("[data-tpledit]").forEach(b => b.addEventListener("click", () => { state.tplEdit = parseInt(b.dataset.tpledit,10); refreshEntities(); })); v.querySelectorAll("[data-tpldel]").forEach(b => b.addEventListener("click", () => { const arr = ensureTemplateCopy(state.entSel); arr.splice(parseInt(b.dataset.tpldel,10), 1); state.tplEdit = null; refreshEntities(); })); const ts = v.querySelector("#tSave"); if(ts) ts.addEventListener("click", () => { const name = (v.querySelector("#tName").value||"").trim(); if(!name){ toast("Give the attribute a name"); return; } const dtype = v.querySelector("#tDtype").value, units = (v.querySelector("#tUnits").value||"").trim(), update = v.querySelector("#tUpdate").value; const arr = ensureTemplateCopy(state.entSel); if(state.tplEdit != null && arr[state.tplEdit]){ arr[state.tplEdit] = { name:name, dtype:dtype, units:units, update:update }; } else { arr.push({ name:name, dtype:dtype, units:units, update:update }); } state.tplEdit = null; refreshEntities(); toast("Attributes updated"); }); const tc = v.querySelector("#tCancel"); if(tc) tc.addEventListener("click", () => { state.tplEdit = null; refreshEntities(); }); const tr = v.querySelector("#tReset"); if(tr) tr.addEventListener("click", () => { delete state.templates[state.entSel]; state.tplEdit = null; refreshEntities(); toast("Reset to the default attributes"); }); const td = v.querySelector("#entToDesigner"); if(td) td.addEventListener("click", () => goTab("designer")); const er = v.querySelector("#entRemove"); if(er) er.addEventListener("click", () => { const id = state.entSel; let inUse = 0; collectSignals().forEach(x => { if(x.sig.entity === id) inUse++; }); state.customEntities = (state.customEntities||[]).filter(e => e.id !== id); delete state.templates[id]; state.entSel = null; state.tplEdit = null; refreshEntities(); toast(inUse ? ("Entity removed, " + inUse + " signals still reference it") : "Entity removed"); }); } function wireEntities(v){ const dl2 = v.querySelector("#dlLib2"); if(dl2) dl2.addEventListener("click", () => download(safeName()+"-uns-library.json", libraryExport(), "application/json")); const ll2 = v.querySelector("#loadLib2"); if(ll2) ll2.addEventListener("change", (e) => { const f = e.target.files && e.target.files[0]; if(!f) return; const rd = new FileReader(); rd.onload = () => { try { const r = libraryImport(JSON.parse(rd.result)); toast("Library imported, " + r.addedEnt + " new entities"); render(); } catch(err){ toast("That library did not load: " + err.message); } }; rd.readAsText(f); }); const ld = v.querySelector("#libToDesigner"); if(ld) ld.addEventListener("click", () => goTab("designer")); const lc = v.querySelector("#libToConvention"); if(lc) lc.addEventListener("click", () => goTab("convention")); const nt = v.querySelector("#entNewToggle"); if(nt) nt.addEventListener("click", () => { state.entAddOpen = !state.entAddOpen; render(); }); const es = v.querySelector("#entSearch"); if(es) es.addEventListener("input", () => { state.entSearch = es.value; refreshEntList(); }); v.querySelectorAll("[data-entfilter]").forEach(b => b.addEventListener("click", () => { state.entFilter = b.dataset.entfilter; render(); })); const ea = v.querySelector("#entAdd"); if(ea) ea.addEventListener("click", () => { const label = (v.querySelector("#entName").value||"").trim(); if(!label){ toast("Give the entity a name"); return; } const group = v.querySelector("#entGroup").value || "Custom"; const id = slugifyId(label); if(allEntities().some(e => e.id === id)){ toast("An entity with that id already exists"); return; } state.customEntities.push({ id:id, label:label, group:group }); state.entSel = id; state.tplEdit = null; state.entAddOpen = false; state.entFilter = "All"; state.entSearch = ""; render(); toast("Added entity " + label + ". Now give it attributes below."); }); wireEntInner(v); } function renderValidate(v){ state.vmode = state.vmode || "design"; let h = pageIntro( "Check the namespace against the failure modes", "Most UNS projects fail the same handful of ways. This runs your design, or a topic list you paste, against those anti-patterns and tells you what to fix and why. Findings are ranked by how much they bite if left alone.", ["Read the readiness score, then act on the top gaps", "Check the design, or paste an existing topic list, against the anti-patterns", "Open a finding for the reason and the fix"] ); h += readinessHtml(); h += '

Anti-pattern check

'; h += '
'; h += ''; h += ''; h += '
'; let findings = []; if(state.vmode==="design"){ findings = validateDesign(); } else { h += ''; h += '
'; if(state.vText) findings = validateTopicList(state.vText); } if(state.vmode==="design") h += checksSummaryHtml(); if(state.vmode==="design" || state.vText){ const counts = { high:0, med:0, low:0 }; findings.forEach(f => counts[f.sev]++); if(!findings.length){ h += '
Clean passNo anti-patterns found. This is a heuristic check, so treat it as a strong signal, not a certificate.
'; } else { h += '
' + findings.length + ' findings'; ["high","med","low"].forEach(s => { if(counts[s]) h += '' + sevMeter(s) + ' ' + counts[s] + ''; }); h += '
'; findings.forEach(f => h += findingCard(f)); } } h += '

Patterns and anti-patterns

The rules behind these checks, stated plainly. Each pattern is something to do, each anti-pattern is something to avoid.

' + patternsCardsHtml(); v.innerHTML = h; v.querySelectorAll("[data-vmode]").forEach(b => b.addEventListener("click", () => { state.vmode = b.dataset.vmode; render(); })); const vr = v.querySelector("#vRun"); if(vr) vr.addEventListener("click", () => { state.vText = (v.querySelector("#vText").value||""); render(); }); } /* =================================================================== PATTERNS reference =================================================================== */ const PATTERNS = [ { kind:"pattern", title:"Structure is the equipment model", rule:"Build the tree from ISA-95: enterprise, site, area, line, cell.", why:"The plant already has a stable shape. Naming to it means the namespace survives reorganizations of data.", ex:"acme/dayton/packaging/line-1/filler" }, { kind:"pattern", title:"The path names the thing, the payload carries the value", rule:"A topic identifies a measurement. The reading goes in the message.", why:"Putting values in the path turns every reading into a new topic and destroys subscriptions.", ex:"topic fill-rate, payload {\"value\": 142}" }, { kind:"pattern", title:"Data lives on leaves", rule:"Publish on the lowest node, not on a branch that has children.", why:"Leaf publishing keeps subscriptions clean and the tree unambiguous.", ex:"...line-1/filler/state, not ...line-1/state" }, { kind:"pattern", title:"One casing, enforced", rule:"Pick kebab or snake and hold every segment to it.", why:"Predictable casing is what lets a person guess a path and a tool match one.", ex:"fill-rate and reject-count, never Fill Rate" }, { kind:"pattern", title:"Identity in the payload", rule:"Lots and serials are signals, never structural levels.", why:"Identities change constantly. As levels they explode the namespace and couple structure to data.", ex:"current-lot as a signal under the cell" }, { kind:"pattern", title:"The UNS is current state", rule:"Live values in the UNS, history and context in the lakehouse.", why:"Mixing history into the UNS turns the live layer into another slow silo.", ex:"UNS holds now, the lakehouse holds the record" }, { kind:"anti", title:"Controller addresses as names", rule:"Do not name topics after PLC tags like DB1.DBW0.", why:"It binds the namespace to wiring. The next controller change breaks every subscriber.", ex:"weld-current, not DB1.DBW2" }, { kind:"anti", title:"Identifiers as levels", rule:"Do not make lot or serial numbers into topic levels.", why:"The namespace grows without bound and structure tracks data that never sits still.", ex:"avoid .../lot-4471/temperature" }, { kind:"anti", title:"Flat or vendor shaped tree", rule:"Do not dump everything under one level or mirror a SCADA tag list.", why:"A flat namespace loses the equipment context that makes a UNS more than a tag database.", ex:"avoid plant/tag00417" }, { kind:"anti", title:"No governance", rule:"Do not ship a namespace without an owner and a version.", why:"Ungoverned namespaces drift within weeks as teams add their own conventions.", ex:"name an owner, version the standard" } ]; /* ---------- Readiness assessment ---------- */ function grade(s){ return s>=85?"A" : s>=70?"B" : s>=55?"C" : s>=40?"D" : "F"; } function assessDesign(){ const sigs = collectSignals(); const present = presentEntities(); const presentList = Object.keys(present); let leaves=0, emptyLeaves=0, maxDepth=0; (function w(n,d){ maxDepth=Math.max(maxDepth,d); const kids=n.children||[]; if(!kids.length){ leaves++; if(!(n.signals||[]).length) emptyLeaves++; } kids.forEach(c=>w(c,d+1)); })(state.tree,0); const usedEnt = presentList.length, totalEnt = ENTITIES.length; const unusedEnt = ENTITIES.filter(e=>!present[e.id]).map(e=>e.label); const ucResults = USE_CASES.map(uc => { const presN = uc.entities.filter(eid => present[eid]).length; const ok = presN === uc.entities.length; const miss = uc.entities.filter(eid=>!present[eid]).map(entityLabel); return { id:uc.id, name:uc.name, ok:ok, applicable: presN > 0, miss:miss }; }); const ucApplicable = ucResults.filter(r => r.applicable); const ucSatApplicable = ucApplicable.filter(r => r.ok).length; const ucSat = ucResults.filter(r=>r.ok).length; const consResults = consumersOn().map(c => { const n = sigs.filter(x=>consumerWants(c,x.sig.entity)).length; return { id:c.id, name:c.name, n:n }; }); const starved = consResults.filter(r=>r.n===0); const withCtx = presentList.filter(eid => contextFor(eid).length).length; const govDefined = !!(state.governance && state.governance.framework && Object.keys(state.governance.framework).length); const ownerSet = !!(state.governance && state.governance.owner); const sStructure = leaves ? Math.round(100*(leaves-emptyLeaves)/leaves) : 0; const sCoverage = Math.round(100*Math.min(1, usedEnt/12)); const sUseCases = ucApplicable.length ? Math.round(100*ucSatApplicable/ucApplicable.length) : 0; const _cOn = consumersOn().length; const sConsumers = _cOn ? Math.round(100*(_cOn-starved.length)/_cOn) : 0; const sGov = (govDefined?70:0) + (ownerSet?30:0); const overall = Math.round((sStructure+sCoverage+sUseCases+sConsumers+sGov)/5); return { sigs:sigs.length, leaves:leaves, emptyLeaves:emptyLeaves, maxDepth:maxDepth, usedEnt:usedEnt, totalEnt:totalEnt, unusedEnt:unusedEnt, ucResults:ucResults, ucSat:ucSat, consResults:consResults, starved:starved, withCtx:withCtx, govDefined:govDefined, ownerSet:ownerSet, overall:overall, dims:[ { id:"structure", label:"Structure", score:sStructure, note: emptyLeaves ? (emptyLeaves + " cells publish nothing") : "every cell publishes" }, { id:"coverage", label:"Entity coverage", score:sCoverage, note: usedEnt + " of " + totalEnt + " entity types in use" }, { id:"usecases", label:"Use case readiness", score:sUseCases, note: ucApplicable.length ? (ucSatApplicable + " of " + ucApplicable.length + " relevant use cases ready") : "no use cases apply yet" }, { id:"consumers", label:"Consumer readiness", score:sConsumers, note: (consumersOn().length-starved.length) + " of " + consumersOn().length + " have data to read" }, { id:"governance", label:"Governance", score:sGov, note: govDefined ? (ownerSet?"defined with an owner":"defined, no owner set") : "not defined yet" } ] }; } function readinessHtml(){ const a = assessDesign(); let h = '

Readiness score

A heuristic read on whether this namespace can serve the work. Higher is better. Treat it as a way to spot gaps, not a certificate.

'; h += '
' + a.overall + 'of 100Grade ' + grade(a.overall) + '
'; h += '
'; a.dims.forEach(d => { h += '
' + esc(d.label) + ''; h += ''; h += '' + d.score + ' ' + grade(d.score) + '
'; h += '
' + esc(d.note) + '
'; }); h += '
'; const gaps = []; if(a.emptyLeaves) gaps.push(a.emptyLeaves + " cells publish no signals. Open them on the Designer and attach data, or remove them."); a.ucResults.filter(r=>r.applicable && !r.ok).forEach(r => gaps.push("Use case partly there: " + r.name + ". Missing " + r.miss.join(", ") + ". Add signals typed to those entities.")); if(a.starved.length) gaps.push("Consumers with nothing to read: " + a.starved.map(s=>s.name).join(", ") + "."); if(!a.govDefined) gaps.push("Governance is not defined. Set the federation policy on the Governance tab."); if(a.unusedEnt.length && a.unusedEnt.length <= 8) gaps.push("Entity types never used: " + a.unusedEnt.join(", ") + ". Fine if that is intentional."); if(gaps.length){ h += '
Top gaps
    '; gaps.slice(0,6).forEach(g => h += '
  • ' + esc(g) + '
  • '); h += '
'; } else h += '
Looking solidNo major gaps detected by the heuristic.
'; return h + '
'; } /* ---------- Brownfield import ---------- */ function guessSignal(name){ const n = (name||"").toLowerCase(); const map = [["temp","process"],["pressure","process"],["bar","process"],["flow","material_movement"],["level","inventory"],["fault","equipment"],["alarm","equipment"],["state","equipment"],["status","equipment"],["mode","equipment"],["speed","operation"],["rate","operation"],["count","operation"],["cycle","operation"],["oee","operation"],["lot","lot"],["batch","lot"],["serial","serialized_unit"],["unit","serialized_unit"],["torque","quality_test"],["result","quality_test"],["test","quality_test"],["pass","quality_test"],["weight","quality_test"],["reject","nonconformance"],["scrap","nonconformance"],["defect","nonconformance"],["order","order"],["recipe","recipe"],["spec","specification"],["onhand","inventory"],["stock","inventory"],["ship","shipment"],["part","product"],["operator","personnel"]]; let entity = "equipment"; for(let i=0;is.trim()).filter(Boolean); if(!lines.length) return null; const slashes = lines.reduce((a,l)=>a+(l.split("/").length-1),0); const dots = lines.reduce((a,l)=>a+(l.split(".").length-1),0); const delim = slashes >= dots ? "/" : "."; const root = node("enterprise", "Imported Plant", [], []); const levelOrder = ["site","area","line","cell"]; let count = 0; lines.forEach(line => { const parts = line.split(delim).map(s=>s.trim()).filter(Boolean); if(parts.length < 2) return; const sigName = parts[parts.length-1]; let segs = parts.slice(0, parts.length-1); let startIdx = 0; if(segs.length > 4){ root.name = segs[0]; startIdx = 1; } const used = segs.slice(startIdx); let cur = root; used.forEach((nm, i) => { if(i >= levelOrder.length) return; const lvl = levelOrder[i]; let child = (cur.children||[]).find(c => c.name === nm && c.level === lvl); if(!child){ child = node(lvl, nm, [], []); cur.children.push(child); } cur = child; }); const g = guessSignal(sigName); if(!(cur.signals||[]).some(s => s.name === sigName)){ cur.signals.push(sig(sigName, g.entity, g.dtype, "", "on change")); count++; } }); return count ? reidTree(root) : null; } function patternsCardsHtml(){ let h = '
'; PATTERNS.forEach(p => { const anti = p.kind==="anti"; h += '
' + (anti?"Anti-pattern":"Pattern") + '
'; h += '

' + esc(p.title) + '

'; h += '

' + esc(p.rule) + '

'; h += '

' + esc(p.why) + '

'; h += '
' + esc(p.ex) + '
'; }); return h + '
'; } function renderPatterns(v){ let h = pageIntro( "Patterns and anti-patterns", "The design rules behind the validator, stated plainly. Each pattern is something to do and each anti-pattern is something to avoid, with the reason and a short example. This is the reference, the validator is the same logic applied to your design.", ["Read the patterns to design well", "Read the anti-patterns to know what the validator flags", "Apply them on the Designer and Convention tabs"] ); h += '
'; PATTERNS.forEach(p => { const anti = p.kind==="anti"; h += '
' + (anti?"Anti-pattern":"Pattern") + '
'; h += '

' + esc(p.title) + '

'; h += '

' + esc(p.rule) + '

'; h += '

' + esc(p.why) + '

'; h += '
' + esc(p.ex) + '
'; }); h += '
'; v.innerHTML = h; } /* =================================================================== DIAGRAM: namespace tree SVG + five-layer placement =================================================================== */ function layoutTree(){ const rows = []; let y = 0; (function rec(n, depth){ rows.push({ node:n, depth:depth, y:y }); y++; (n.children||[]).forEach(c => rec(c, depth+1)); })(state.tree, 0); return rows; } function svgTree(forDownload){ const allRows = layoutTree(); const TREE_CAP = 60; const truncated = !forDownload && allRows.length > TREE_CAP; const rows = truncated ? allRows.slice(0, TREE_CAP) : allRows; const rowH = 34, indent = 26, padX = 16, padY = 18, badge = 18; const labelMax = rows.reduce((m,r) => Math.max(m, r.depth*indent + (r.node.name.length*8) + 120), 280); const W = Math.min(Math.max(labelMax, 360), 1100), H = padY*2 + rows.length*rowH + (truncated?26:0); const ink="#13202c", cyan="#2aa8e0", line="#cdd7e1", bg="#ffffff", muted="#6b7884"; const dash = { enterprise:"", site:"", area:"5,3", line:"2,3", cell:"" }; let s = ''; s += ''; // connectors rows.forEach((r,i) => { if(r.depth===0) return; // find parent row (nearest previous with depth-1) let pj=-1; for(let j=i-1;j>=0;j--){ if(rows[j].depth===r.depth-1){ pj=j; break; } } if(pj<0) return; const px = padX + (r.depth-1)*indent + badge/2; const cyP = padY + rows[pj].y*rowH + rowH/2; const cyC = padY + r.y*rowH + rowH/2; const cx = padX + r.depth*indent; s += ''; }); // nodes rows.forEach(r => { const x = padX + r.depth*indent, cy = padY + r.y*rowH + rowH/2; const lvl = LEVEL_BY_ID[r.node.level] || { badge:"?" }; const dpat = dash[r.node.level] || ""; s += ''; s += '' + svgEscX(lvl.badge) + ''; const sc = (r.node.signals||[]).length; s += '' + svgEscX(r.node.name) + (sc?' ':'') + ''; if(sc) s += '' + sc + ' signal' + (sc===1?'':'s') + ''; }); if(truncated){ s += '+ ' + (allRows.length - rows.length) + ' more nodes. Download the SVG for the full tree.'; } return s + ''; } function svgEscX(s){ return String(s==null?"":s).replace(/[&<>]/g, c => ({"&":"&","<":"<",">":">"}[c])); } function placementModel(){ const cs = collectSignals(); const present = presentEntities(); const presentList = Object.keys(present); let leaves=0, depth=0; const leafNodes=[]; walk(state.tree, (n, p, anc) => { if(!(n.children||[]).length){ leaves++; if((n.signals||[]).length) leafNodes.push(n); } depth = Math.max(depth, (anc?anc.length:0)); }); const fed = consumersOn().filter(c => cs.some(x => consumerWants(c, x.sig.entity))); const starved = consumersOn().filter(c => !cs.some(x => consumerWants(c, x.sig.entity))); const ctx = contextOn().filter(p => p.appliesTo.indexOf("*")!==-1 || presentList.some(eid => p.appliesTo.indexOf(eid)!==-1)); return { cs:cs, present:present, presentList:presentList, leaves:leaves, depth:depth, leafNodes:leafNodes, fed:fed, starved:starved, ctx:ctx, topics:cs.length, plant:(state.tree && state.tree.name)?state.tree.name:"Plant" }; } function placementAccordionHtml(){ const m = placementModel(); const open = state.placeLayer; const conv = state.convention || {}; const sepName = ({ "/":"slash", ".":"dot", ":":"colon", "_":"underscore", "-":"hyphen" })[conv.separator] || conv.separator; let consumeDetail = '

Dashboards, analytics, and AI each subscribe to the slice they need, with nothing custom per machine.

'; if(m.fed.length){ consumeDetail += '
'; m.fed.forEach(c => { const n = m.cs.filter(x => consumerWants(c, x.sig.entity)).length; consumeDetail += '
' + esc(c.name) + '' + esc(c.sub) + ', ' + n + ' signals
'; }); consumeDetail += '
'; } if(m.starved.length) consumeDetail += '

Waiting for data: ' + esc(m.starved.map(c => c.name).join(", ")) + '. Add the signals they read to feed them.

'; const lakeDetail = '

The historian and lakehouse archive every topic for trends, history, context, and quality. This is the system of record behind the namespace.

Archives all ' + m.topics + ' topics.

The line that matters

The Unified Namespace is current state, the live shape of the plant right now. The lakehouse is the system of record. Keep that split clear and the namespace stays fast. Blur it and the namespace becomes another silo.

'; const unsDetail = '

The live, current state view. The naming convention and the transport live here. This is the part this tool builds.

' + '
' + '
Plant' + esc(m.plant) + '
' + '
Topics' + m.topics + '
' + '
Levels deep' + (m.depth+1) + '
' + '
Cells publishing' + m.leafNodes.length + '
' + '
Separator' + esc(sepName) + '
' + '
Transport' + (conv.target==="sparkplug"?"Sparkplug B":"MQTT") + '
' + '
' + '

Edit it on the Designer. Read the full topic list on the Topics tab.

'; let integDetail = '

Edge mapping turns controller tags into the semantic names above. Context systems add the meaning that raw tags lack.

'; if(m.ctx.length){ integDetail += '
'; m.ctx.forEach(p => { integDetail += '
' + esc(p.name) + '' + esc(p.provides.slice(0,4).map(f => f.replace(/_/g," ")).join(", ")) + '
'; }); integDetail += '
'; } else integDetail += '

No context systems apply to the entities in this design yet.

'; let edgeDetail = '

PLCs, sensors, and devices publish once to the edge. ' + m.leafNodes.length + ' cells are publishing in this design.

'; if(m.leafNodes.length){ edgeDetail += '
'; m.leafNodes.slice(0,12).forEach(n => { edgeDetail += '
' + esc(nodePath(n.id)) + '' + (n.signals||[]).length + ' signal' + ((n.signals||[]).length===1?'':'s') + '
'; }); edgeDetail += '
'; if(m.leafNodes.length>12) edgeDetail += '

Showing 12 of ' + m.leafNodes.length + ' cells.

'; } const layers = [ { id:"consume", name:"Consumption", annot: m.fed.length + " systems subscribing", detail: consumeDetail }, { id:"lake", name:"Governed lakehouse", annot: "archives all " + m.topics + " topics", detail: lakeDetail }, { id:"uns", name:"Unified Namespace", here:true, annot: m.topics + " topics, " + (m.depth+1) + " levels", detail: unsDetail }, { id:"integ", name:"Integration and contextualization", annot: m.ctx.length + " context systems", detail: integDetail }, { id:"edge", name:"Edge and control", annot: m.leafNodes.length + " cells on the floor", detail: edgeDetail } ]; let h = '
'; layers.forEach(L => { const isOpen = open===L.id; h += '
'; h += ''; if(isOpen) h += '
' + L.detail + '
'; h += '
'; }); return h + '
'; } function svgFiveLayer(forDownload){ const cs = collectSignals(); const topics = cs.length; let leaves=0, depth=0; walk(state.tree, (n, p, anc) => { if(!(n.children||[]).length) leaves++; depth = Math.max(depth, (anc?anc.length:0)); }); const fed = consumersOn().filter(c => cs.some(x => consumerWants(c, x.sig.entity))).length; const layers = [ { name:"Consumption", note:"Dashboards, analytics, AI. Reads from both layers.", annot: fed + " systems subscribing" }, { name:"Governed lakehouse", note:"System of record. History, context, and quality.", annot: "archives all " + topics + " topics" }, { name:"Unified Namespace", note:"Current state. Naming and transport. You are here.", here:true, annot: topics + " topics, " + (depth+1) + " levels" }, { name:"Integration and contextualization", note:"Edge mapping from controllers to semantic names.", annot: topics + " names mapped" }, { name:"Edge and control", note:"PLCs, sensors, and devices on the plant floor.", annot: leaves + " cells on the floor" } ]; const W=720, rowH=64, padY=16, padX=16, H=padY*2 + layers.length*(rowH+10); const ink="#13202c", cyan="#2aa8e0", line="#cdd7e1", bg="#ffffff", soft="#f5f8fb", muted="#6b7884"; let s = ''; s += ''; layers.forEach((L,i) => { const y = padY + i*(rowH+10); const fill = L.here ? soft : "#ffffff"; const sw = L.here ? 2.4 : 1.2; s += ''; if(L.here){ s += ''; } s += '' + svgEscX(L.name) + (L.here?' (UNS)':'') + ''; s += '' + svgEscX(L.note) + ''; if(L.annot) s += '' + svgEscX(L.annot) + ''; }); return s + ''; } /* =================================================================== GOVERNANCE =================================================================== */ const GOV_LEVELS = { corporate: { short:"Corporate", label:"Corporate mandate", cls:"gly-corporate", desc:"Set once centrally. Every site complies. This is what keeps topics interoperable across the enterprise." }, shared: { short:"Shared", label:"Shared guardrail", cls:"gly-shared", desc:"Corporate sets the rule, sites fill in the detail within bounds." }, site: { short:"Site", label:"Site autonomy", cls:"gly-site", desc:"Sites decide locally, inside the corporate guardrails." } }; const GOV_AREAS = [ { id:"structure", title:"Structure and local naming", level:"shared", why:"The split everyone asks about first.", sample:"Corporate fixes the level model, enterprise, site, area, line, then cell, and owns the enterprise and site names. Sites name their own areas, lines, and cells within that model. Nobody invents a new level or reorders them." }, { id:"convention", title:"Naming convention", level:"corporate", why:"Consistency is what lets a path be predicted.", sample:"Casing and separators are uniform across every site. Kebab or snake is chosen once and enforced. A check runs against new topics before they go live, the same checks the Validate tab applies. Violations block publishing until they are fixed." }, { id:"ownership", title:"Ownership and roles", level:"corporate", why:"Someone has to hold the convention or it drifts.", sample:"A single namespace owner holds the standard at the corporate level. An architecture review group approves structural changes. Site leads publish within their own branch and cannot change the convention." }, { id:"versioning", title:"Versioning", level:"corporate", why:"A topic should trace to the rules it was built under.", sample:"The standard uses semantic versioning, owned centrally. Breaking changes raise the major version. Every change is recorded in a changelog with date, author, and reason." }, { id:"change", title:"Change control", level:"shared", why:"Uncontrolled additions are how a clean namespace rots.", sample:"A site proposes a new node or signal in writing. Corporate checks it against the equipment model and the casing rules, then approves before it is published. Rejected proposals are recorded with the reason." }, { id:"access", title:"Access and security", level:"corporate", why:"Publish rights and read rights are not the same thing.", sample:"The authentication and access model is set centrally. A publisher can write only to its own equipment path. Consumers get read access to the slices they need and nothing wider. No anonymous clients." }, { id:"onboarding", title:"Onboarding producers and consumers", level:"site", why:"Sites add their own equipment day to day.", sample:"A site maps its controller tags to semantic names at the edge, once, then publishes to its branch. The site grants a new consumer a read subscription scoped to the topics it needs, within the corporate access model." }, { id:"quality", title:"Payload and quality", level:"shared", why:"A namespace is only as good as what flows through it.", sample:"The payload schema is set centrally, value plus timestamp plus quality plus context, and every signal carries units and a data type. Sites decide which signals to publish and at what cadence within that schema." }, { id:"truth", title:"Source of truth", level:"corporate", why:"The UNS is live state, not the system of record.", sample:"The Unified Namespace holds current state. The governed lakehouse is the system of record for history, context, and quality. Reporting and analytics read the lakehouse, not the live namespace." }, { id:"lifecycle", title:"Lifecycle and deprecation", level:"shared", why:"Topics that never die clutter the namespace.", sample:"A retired topic is marked deprecated and kept for a defined window so consumers can migrate, then removed. Sites archive a decommissioned branch rather than leaving it publishing stale data." }, { id:"review", title:"Review and audit", level:"shared", why:"Drift is caught by looking, on a schedule.", sample:"Corporate audits the namespace on a set cadence against the standard, checking for anti-patterns, orphaned topics, and convention drift. Sites run a lighter local review between audits. Findings are logged and assigned." } ]; function ensureGovFramework(){ if(!state.governance.framework) state.governance.framework = {}; GOV_AREAS.forEach(a => { const fr = state.governance.framework[a.id]; if(!fr || typeof fr !== "object" || typeof fr.policy !== "string") state.governance.framework[a.id] = { policy:a.sample, level:a.level }; else if(!GOV_LEVELS[fr.level]) fr.level = a.level; }); } function resetGovFramework(){ state.governance.framework = {}; GOV_AREAS.forEach(a => state.governance.framework[a.id] = { policy:a.sample, level:a.level }); } function resetGovArea(id){ const a = GOV_AREAS.find(x => x.id===id); if(a) state.governance.framework[id] = { policy:a.sample, level:a.level }; } function govAreaEdited(a){ const fr = state.governance.framework[a.id]; return fr.policy.trim() !== a.sample.trim() || fr.level !== a.level; } function govSummaryHtml(){ ensureGovFramework(); let h = '
'; ["corporate","shared","site"].forEach(lv => { const L = GOV_LEVELS[lv]; const areas = GOV_AREAS.filter(a => state.governance.framework[a.id].level===lv); h += '

' + esc(L.label) + '

' + esc(L.desc) + '

'; if(areas.length) areas.forEach(a => h += '' + esc(a.title) + ''); else h += 'None assigned'; h += '
'; }); return h + '
'; } function renderGovernance(v){ ensureGovFramework(); const g = state.governance; let h = pageIntro( "Govern it like a federation", "The hardest part of UNS governance is not the rules, it is who owns each one. Some things must be set once at the corporate level so topics stay interoperable across every site. Others are left to sites as local autonomy. This tab makes that split explicit. Each policy area is marked as a corporate mandate, a shared guardrail, or site autonomy, with a recommended policy you can keep or rewrite. It all flows into the exported standard.", ["Read the split at the top, corporate versus shared versus site", "Open any area to set who decides and edit the policy", "Export the standard with the framework and the decision rights"] ); h += '

Decision rights at a glance

'; h += govSummaryHtml(); h += '
'; h += ''; h += ''; h += ''; h += '
'; h += '
Policy areasopen an area to set who decides and edit the policy
'; GOV_AREAS.forEach(a => { const fr = g.framework[a.id], open = !!state.govOpen[a.id], edited = govAreaEdited(a), L = GOV_LEVELS[fr.level]; h += '
'; h += ''; if(open){ h += '
'; h += '
Who decides this
'; ["corporate","shared","site"].forEach(lv => { h += ''; }); h += '

' + esc(L.desc) + '

'; h += '
Recommended policy, for reference
' + esc(a.sample) + '
'; h += '
Your policy, click in and type to edit
'; if(edited) h += '
'; h += '
'; } h += '
'; }); v.innerHTML = h; const bind = (id, key) => { const el = v.querySelector(id); if(el) el.addEventListener("change", () => { state.governance[key] = el.value; toast("Saved"); }); }; bind("#gOwner","owner"); bind("#gVer","version"); bind("#gCad","cadence"); v.querySelectorAll("[data-govtoggle]").forEach(b => b.addEventListener("click", () => { const id=b.dataset.govtoggle; state.govOpen[id] = !state.govOpen[id]; render(); })); v.querySelectorAll("[data-govlvl]").forEach(b => b.addEventListener("click", () => { state.governance.framework[b.dataset.govlvl].level = b.dataset.lvl; render(); toast("Decision level set"); })); v.querySelectorAll("[data-gov]").forEach(t => t.addEventListener("change", () => { state.governance.framework[t.dataset.gov].policy = t.value; render(); })); v.querySelectorAll("[data-govareset]").forEach(b => b.addEventListener("click", () => { resetGovArea(b.dataset.govareset); render(); toast("Area reset to recommended"); })); const gr = v.querySelector("#govReset"); if(gr) gr.addEventListener("click", () => { resetGovFramework(); render(); toast("Framework reset to recommended"); }); } /* =================================================================== PAYLOAD templates =================================================================== */ function sampleValue(dtype){ return dtype==="Boolean"?true : dtype==="Int"?3 : dtype==="Float"?12.5 : dtype==="DateTime"?"2026-01-01T00:00:00Z" : "ABC123"; } function spType(dtype){ return dtype==="Boolean"?"Boolean" : dtype==="Int"?"Int32" : dtype==="Float"?"Float" : dtype==="DateTime"?"DateTime" : "String"; } function mqttPayloadSample(n, s){ const o = { value: sampleValue(s.dtype), timestamp: "2026-01-01T00:00:00Z", quality: "good", dataType: s.dtype, entity: s.entity }; if(s.units) o.units = s.units; return o; } function payloadSchemaText(){ if(state.convention.target === "sparkplug"){ const devs = sparkplugDevices(); if(!devs.length) return "{}"; const d = devs[0]; const payload = { timestamp: "2026-01-01T00:00:00Z", seq: 0, metrics: d.metrics.slice(0,6).map((m,i) => ({ name: m.name, alias: i+1, dataType: spType(m.dtype), value: sampleValue(m.dtype) })) }; return "// Sparkplug B DDATA payload for device\n// topic: " + d.topic + "\n" + JSON.stringify(payload, null, 2); } const cs = collectSignals(); if(!cs.length) return "{}"; const ex = cs[0]; return "// MQTT payload for topic\n// " + signalTopicMqtt(ex.node.id, ex.sig) + "\n" + JSON.stringify(mqttPayloadSample(ex.node, ex.sig), null, 2); } /* =================================================================== DATA FLOW: hub and spoke, producers to UNS to consumers =================================================================== */ const CONSUMERS = [ { id:"historian", name:"Historian and lakehouse", kind:"history", entities:["*"], sub:"every topic", uses:"Archives every signal for trends, history, and the system of record. This is the governed lakehouse behind the UNS." }, { id:"oee", name:"OEE dashboard", kind:"OEE", entities:["operation","equipment"], sub:"state, rate, and counts", uses:"Computes availability, performance, and quality in real time from machine state, run rate, and counts." }, { id:"maint", name:"Predictive maintenance", kind:"maint", entities:["equipment","process"], sub:"equipment health signals", uses:"Watches temperature, current, pressure, and fault signals for drift and anomalies to flag failures early." }, { id:"quality", name:"Quality and SPC", kind:"quality", entities:["quality_test","nonconformance","specification"], sub:"test results and nonconformances", uses:"Pulls test results and nonconformances for control charts, release decisions, and corrective action." }, { id:"mes", name:"MES and traceability", kind:"MES", entities:["lot","sublot","serialized_unit","material_movement","transaction","operation"], sub:"lots, serials, and movements", uses:"Builds genealogy and tracks work in process, linking every unit and lot to where and when it moved." }, { id:"erp", name:"ERP and supply chain", kind:"ERP", entities:["inventory","order","demand","shipment","buffer"], sub:"inventory, orders, and shipments", uses:"Keeps stock, orders, and shipments current so planning sees real material positions." }, { id:"energy", name:"Energy and utilities", kind:"energy", entities:["process","material_movement"], sub:"flows, temperatures, and pressures", uses:"Tracks process flows and conditions to monitor consumption and find waste." }, { id:"hmi", name:"SCADA and alerting", kind:"HMI", entities:["equipment","event"], sub:"state and events", uses:"Drives operator screens and alarms from live state and event signals." } ]; const CONSUMER_BY_ID = {}; CONSUMERS.forEach(c => CONSUMER_BY_ID[c.id] = c); function consumerWants(c, entityId){ return c.entities.indexOf("*") !== -1 || c.entities.indexOf(entityId) !== -1; } function subtreeSignals(n){ const out=[]; walk(n, x => (x.signals||[]).forEach(s => out.push({ node:x, sig:s }))); return out; } function producersAtLevel(level){ const out = []; walk(state.tree, n => { if(n.level===level){ const sigs = subtreeSignals(n); if(sigs.length) out.push({ id:n.id, node:n, sigs:sigs, count:sigs.length }); } }); return out; } function flowModel(){ const level = state.flow.level; let producers = producersAtLevel(level); if(!producers.length){ // fall back to the shallowest level that has signals ["area","line","cell","site"].some(l => { const p = producersAtLevel(l); if(p.length){ producers = p; state.flow.level = l; return true; } return false; }); } const producersTotal = producers.length; if(producers.length > 16){ producers = producers.slice().sort((a,b)=> (b.sigs.length - a.sigs.length) || String(a.node.name).localeCompare(String(b.node.name))).slice(0,16); } const all = collectSignals(); const consumers = consumersOn().map(c => { const received = all.filter(x => consumerWants(c, x.sig.entity)); const fedBy = producers.filter(p => p.sigs.some(x => consumerWants(c, x.sig.entity))).map(p => p.id); return { consumer:c, received:received, count:received.length, fedBy:fedBy }; }).filter(c => c.count > 0); producers.forEach(p => { p.consumers = consumers.filter(c => c.fedBy.indexOf(p.id) !== -1).map(c => c.consumer.id); }); return { producers:producers, consumers:consumers, context:contextProvidersForModel(), level:level, producersTotal:producersTotal }; } /* ---- flow svg ---- */ /* =================================================================== CONTEXT: systems that enrich the namespace, and use cases =================================================================== */ const CONTEXT_PROVIDERS = [ { id:"mes", name:"MES", short:"MES", provides:["work_order","product","lot","operator","shift"], appliesTo:["operation","equipment","quality_test","nonconformance","serialized_unit","lot","material_movement"], note:"Merges the active work order and production context onto live signals." }, { id:"erp", name:"ERP", short:"ERP", provides:["sales_order","customer","material_master","cost_center"], appliesTo:["inventory","shipment","order","lot","serialized_unit"], note:"Adds commercial and material master context." }, { id:"lims", name:"Quality and LIMS", short:"LIMS", provides:["spec_min","spec_max","sampling_plan","method"], appliesTo:["quality_test","nonconformance","specification"], note:"Adds specification limits and sampling context to quality data." }, { id:"aps", name:"Scheduling and APS", short:"Scheduling", provides:["planned_rate","takt","scheduled_start"], appliesTo:["operation","process","order","demand"], note:"Adds the plan so actuals can be compared to target." }, { id:"cmms", name:"Asset management", short:"Asset mgmt", provides:["asset_id","install_date","maintenance_state"], appliesTo:["equipment"], note:"Adds asset identity and maintenance state to equipment signals." } ]; const CTX_BY_ID = {}; CONTEXT_PROVIDERS.forEach(p => CTX_BY_ID[p.id] = p); /* ---------- Editable consumers and context systems ---------- */ function ensureSystems(){ if(!Array.isArray(state.consumers) || !state.consumers.length){ state.consumers = CONSUMERS.map(c => Object.assign({ on:true, custom:false }, JSON.parse(JSON.stringify(c)))); } if(!Array.isArray(state.context) || !state.context.length){ state.context = CONTEXT_PROVIDERS.map(p => Object.assign({ on:true, custom:false }, JSON.parse(JSON.stringify(p)))); } } function consumersAll(){ ensureSystems(); return state.consumers; } function consumersOn(){ return consumersAll().filter(c => c.on !== false); } function contextAll(){ ensureSystems(); return state.context; } function contextOn(){ return contextAll().filter(p => p.on !== false); } function consumerById(id){ return consumersAll().find(c => c.id === id); } function ctxById(id){ return contextAll().find(p => p.id === id); } function sysFind(k, id){ return (k === "cons" ? consumersAll() : contextAll()).find(x => x.id === id); } function sysNewId(pfx){ return pfx + Date.now().toString(36) + Math.floor(Math.random()*1000); } function sysRowHtml(s, kind){ const on = s.on !== false; const isAll = kind === "cons" && (s.entities||[]).indexOf("*") !== -1; const editing = state.sysEdit === (kind + ":" + s.id); const list = kind === "cons" ? (s.entities||[]) : (s.appliesTo||[]); let h = '
'; h += ''; h += ''; h += '' + (isAll ? "reads every topic" : (list.length + (kind === "cons" ? " entities read" : " entities enriched"))) + ''; h += ''; if(!isAll) h += ''; if(s.custom) h += ''; h += '
'; if(editing && !isAll){ h += '
'; allEntities().forEach(e => { const sel = list.indexOf(e.id) !== -1; h += ''; }); h += '
'; } return h; } function systemsEditorHtml(){ ensureSystems(); let h = '
Your systems, edit who reads and writes'; h += '

These are the systems on the diagram. Rename them to match your plant, turn off the ones you do not have, change what each one reads, or add your own. This personalizes the flow, the readiness score, and the governance.

'; h += '

Consumers, systems that read the namespace

'; consumersAll().forEach(c => { h += sysRowHtml(c, "cons"); }); h += '
'; h += '

Context systems, systems that write business context

'; contextAll().forEach(p => { h += sysRowHtml(p, "ctx"); }); h += '
'; h += '
'; return h + '
'; } function wireSystems(v){ v.querySelectorAll("[data-systoggle]").forEach(b => b.addEventListener("click", () => { const p = b.dataset.systoggle.split(":"); pushUndo(); const s = sysFind(p[0], p[1]); if(s) s.on = (s.on === false); render(); })); v.querySelectorAll("[data-sysname]").forEach(inp => { if(!inp._wired){ inp._wired = true; inp.addEventListener("change", () => { const p = inp.dataset.sysname.split(":"); const s = sysFind(p[0], p[1]); if(s && s.name !== inp.value){ pushUndo(); s.name = inp.value; render(); } }); } }); v.querySelectorAll("[data-sysedit]").forEach(b => b.addEventListener("click", () => { const key = b.dataset.sysedit; state.sysEdit = (state.sysEdit === key ? null : key); render(); })); v.querySelectorAll("[data-sysdel]").forEach(b => b.addEventListener("click", () => { const p = b.dataset.sysdel.split(":"); pushUndo(); if(p[0] === "cons") state.consumers = consumersAll().filter(x => x.id !== p[1]); else state.context = contextAll().filter(x => x.id !== p[1]); if(state.sysEdit === b.dataset.sysdel) state.sysEdit = null; render(); })); v.querySelectorAll("[data-sysent]").forEach(b => b.addEventListener("click", () => { const p = b.dataset.sysent.split(":"); pushUndo(); const s = sysFind(p[0], p[1]); if(s){ const arr = p[0] === "cons" ? (s.entities = s.entities||[]) : (s.appliesTo = s.appliesTo||[]); const i = arr.indexOf(p[2]); if(i === -1) arr.push(p[2]); else arr.splice(i,1); } render(); })); const ca = v.querySelector("#consAdd"); if(ca) ca.addEventListener("click", () => { const inp = v.querySelector("#consAddName"); const nm = ((inp && inp.value)||"").trim(); if(!nm){ toast("Type a name for the consumer"); return; } pushUndo(); const id = sysNewId("c"); consumersAll().push({ id:id, name:nm, kind:"custom", entities:[], sub:"", uses:"A system you added.", on:true, custom:true }); state.sysEdit = "cons:" + id; render(); }); const xa = v.querySelector("#ctxAdd"); if(xa) xa.addEventListener("click", () => { const inp = v.querySelector("#ctxAddName"); const nm = ((inp && inp.value)||"").trim(); if(!nm){ toast("Type a name for the context system"); return; } pushUndo(); const id = sysNewId("x"); contextAll().push({ id:id, name:nm, short:nm, provides:[], appliesTo:[], note:"A system you added.", on:true, custom:true }); state.sysEdit = "ctx:" + id; render(); }); const sr = v.querySelector("#sysReset"); if(sr) sr.addEventListener("click", () => { if(typeof window !== "undefined" && window.confirm && !window.confirm("Reset consumers and context systems to the defaults?")) return; pushUndo(); state.consumers = null; state.context = null; ensureSystems(); render(); }); const se = v.querySelector(".sys-editor"); if(se && !se._wired){ se._wired = true; se.addEventListener("toggle", () => { state.sysOpen = !!se.open; }); } } function contextFor(entityId){ return contextOn().filter(p => p.appliesTo.indexOf("*")!==-1 || p.appliesTo.indexOf(entityId)!==-1); } function presentEntities(){ const m={}; collectSignals().forEach(x => m[x.sig.entity]=1); return m; } function contextProvidersForModel(){ const pres = presentEntities(); return contextOn().filter(p => p.appliesTo.indexOf("*")!==-1 || p.appliesTo.some(e => pres[e])); } const CTX_SAMPLE = { work_order:"WO-10482", product:"SKU-3391", lot:"L-2207-A", operator:"OP-17", shift:"A", sales_order:"SO-88120", customer:"CUST-204", material_master:"MM-5567", cost_center:"CC-12", spec_min:9.5, spec_max:10.5, sampling_plan:"AQL 1.0", method:"M-204", planned_rate:150, takt:2.4, scheduled_start:"2026-01-01T06:00:00Z", asset_id:"AS-1180", install_date:"2021-04-12", maintenance_state:"ok" }; function sampleContextValue(field){ return (field in CTX_SAMPLE) ? CTX_SAMPLE[field] : "value"; } function enrichedPayload(n, s){ const o = mqttPayloadSample(n, s); const ctx = {}; let any = false; contextFor(s.entity).forEach(p => p.provides.forEach(fld => { if(!(fld in ctx)){ ctx[fld] = sampleContextValue(fld); any = true; } })); if(any) o.context = ctx; return o; } const USE_CASES = [ { id:"oee", name:"Real-time OEE", outcome:"Live availability, performance, and quality per line.", entities:["operation","equipment"], context:["mes","aps"], consumers:["oee","historian"], story:"Machine state and counts publish from the line. MES merges the active work order and product, and scheduling adds the planned rate. The OEE dashboard turns that into live availability, performance, and quality, with no custom integration to each machine." }, { id:"trace", name:"Track and trace", outcome:"Trace any lot or unit forward and backward in minutes.", entities:["lot","serialized_unit","material_movement","transaction"], context:["mes","erp"], consumers:["mes","quality","erp","historian"], story:"Lots, serials, and movements publish as they happen. MES adds the genealogy and ERP adds the order and customer. A recall query then walks the graph instead of stitching spreadsheets across systems." }, { id:"pdm", name:"Predictive maintenance", outcome:"Flag equipment failures before they stop the line.", entities:["equipment","process"], context:["cmms","mes"], consumers:["maint","historian"], story:"Temperature, current, and fault signals publish from equipment. Asset management adds the asset id and maintenance state, and MES adds what was running. The maintenance model watches for drift and schedules work before a breakdown." }, { id:"release", name:"Batch release", outcome:"Release a batch faster with the data already assembled.", entities:["quality_test","nonconformance","specification","lot"], context:["lims","mes"], consumers:["quality","erp","historian"], story:"Test results publish from the lab and the line. LIMS adds the spec limits and sampling plan, and MES adds the batch. Quality reviews against spec and releases, and ERP frees the stock, all from one assembled record." }, { id:"energy", name:"Energy optimization", outcome:"Cut consumption by tying usage to what is running.", entities:["process","material_movement"], context:["mes","aps"], consumers:["energy","historian"], story:"Flows, temperatures, and pressures publish from the process. MES adds the product and work order so usage ties to output, and scheduling adds the plan. Energy management finds where consumption runs ahead of need." }, { id:"schedule", name:"Schedule adherence", outcome:"See plan against actual on every line as the shift runs.", entities:["order","operation","equipment"], context:["aps","mes"], consumers:["mes","hmi","historian"], story:"Counts and machine state publish from the line. Scheduling adds the planned sequence and rate, and MES adds the active work order. Supervisors watch plan against actual in real time and react before the shift slips, instead of finding out the next morning." }, { id:"andon", name:"Live status and Andon", outcome:"Show every line the floor status and the reason it stopped.", entities:["equipment","event","operation"], context:["mes"], consumers:["hmi","oee","historian"], story:"State changes and stop events publish the moment they happen. MES adds what was running and the downtime reason. Andon boards and HMIs light up live, so operators and supervisors see the same picture and the right people move first." }, { id:"ebr", name:"Batch record genealogy (Pharma)", outcome:"Assemble an electronic batch record and full genealogy for release.", entities:["lot","sublot","quality_test","specification","material_movement"], context:["mes","lims"], consumers:["quality","mes","historian"], story:"Lots, splits, movements, and in process tests publish as the batch runs. MES adds the recipe step and operator, and LIMS adds the spec and sampling plan. The batch record assembles itself as the work happens, so review by exception replaces chasing paper at release." }, { id:"serialization", name:"Unit serialization (Electronics, Med device)", outcome:"Follow each serialized unit through every step and test.", entities:["serialized_unit","operation","quality_test","nonconformance"], context:["mes"], consumers:["mes","quality","historian"], story:"Each unit publishes its serial, the operation it passed through, its test results, and any defect. MES ties the serial to the order and station. A field return then resolves to the exact build history of that one unit, not just the lot it came from." }, { id:"yield", name:"Yield and scrap analysis (Semiconductor)", outcome:"Pinpoint where yield is lost, by step and by product.", entities:["operation","quality_test","nonconformance","lot"], context:["mes"], consumers:["quality","oee","historian"], story:"Pass and fail results, scrap, and rework publish from every step. MES adds the product and process step so loss ties back to where it happened. Engineering sees which step and which product bleed yield, and fixes the step that actually moves the number." }, { id:"spc", name:"Statistical process control (Process)", outcome:"Catch process drift before it turns into scrap.", entities:["quality_test","process","specification"], context:["lims","mes"], consumers:["quality","hmi","historian"], story:"Process variables and test results publish continuously. LIMS adds the control limits and MES adds the product being made. Control charts update live at the line, so an operator sees a trend heading out of bounds and corrects it while the product is still good." } ]; function useCaseModel(uc){ const all = collectSignals(); let producers = producersAtLevel(state.flow.level); if(!producers.length){ ["area","line","cell","site"].some(l => { const p = producersAtLevel(l); if(p.length){ producers = p; state.flow.level = l; return true; } return false; }); } producers = producers.filter(p => p.sigs.some(x => uc.entities.indexOf(x.sig.entity) !== -1)); const consumers = uc.consumers.map(cid => consumerById(cid)).filter(c => c && c.on!==false).map(c => { const received = all.filter(x => consumerWants(c, x.sig.entity)); const fedBy = producers.filter(p => p.sigs.some(x => consumerWants(c, x.sig.entity))).map(p => p.id); return { consumer:c, received:received, count:received.length, fedBy:fedBy }; }).filter(c => c.count > 0); producers.forEach(p => { p.consumers = consumers.filter(c => c.fedBy.indexOf(p.id) !== -1).map(c => c.consumer.id); }); const context = uc.context.map(id => ctxById(id)).filter(p => p && p.on!==false); return { producers:producers, consumers:consumers, context:context, level:state.flow.level }; } function renderUseCases(){ state.uc = state.uc || USE_CASES[0].id; const uc = USE_CASES.find(u => u.id===state.uc) || USE_CASES[0]; let h = '
Choose a use case
'; USE_CASES.forEach(u => { h += ''; }); h += '
'; const model = useCaseModel(uc); h += '
' + esc(uc.name) + '

' + esc(uc.outcome) + '

' + esc(uc.story) + '

'; if(!model.producers.length || !model.consumers.length){ h += '
Not in this design

The current design has no signals for this use case. Load a scenario that produces ' + esc(uc.entities.map(entityLabel).join(", ")) + ', or add them on the Designer tab.

'; return h; } h += '
' + svgFlow(model, { allActive:true, showContext:true }) + '
'; h += '
'; h += '

Signals it needs

'; const need = collectSignals().filter(x => uc.entities.indexOf(x.sig.entity)!==-1).slice(0,30); need.forEach(x => { h += '
' + esc(signalTopicMqtt(x.node.id, x.sig)) + '' + esc(entityLabel(x.sig.entity)) + '
'; }); h += '
'; h += '

Context that enriches it

'; model.context.forEach(p => { h += '
' + esc(p.name) + '' + p.provides.slice(0,4).map(f=>f.replace(/_/g," ")).join(", ") + '
'; }); h += '

Delivered by

'; model.consumers.forEach(c => { h += '
' + esc(c.consumer.name) + '' + c.count + ' signals
'; }); h += '
'; return h; } function spokePath(x1,y1,x2,y2){ const mx=(x1+x2)/2; return "M" + x1 + " " + y1 + " C" + mx + " " + y1 + " " + mx + " " + y2 + " " + x2 + " " + y2; } function reducedMotion(){ try { return window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches; } catch(e){ return false; } } function svgFlow(model, opts){ opts = opts || {}; const sel = (opts.sel!==undefined) ? opts.sel : state.flow.sel; const selKind = (opts.selKind!==undefined) ? opts.selKind : state.flow.selKind; const animOn = (opts.animate!==undefined) ? opts.animate : state.flow.animate; const showContext = (opts.showContext!==undefined) ? opts.showContext : state.flow.context; const allActive = !!opts.allActive; const ink="#13202c", cyan="#2aa8e0", line="#cdd7e1", bg="#ffffff", soft="#f5f8fb", muted="#6b7884", dimc="#aab6c2", amber="#9a5b12"; const P = model.producers, C = model.consumers; const CX = (showContext && model.context) ? model.context : []; const W = 940, rowH = 62, botPad = 28; const ctxBandH = CX.length ? 104 : 0; const topPad = 64 + ctxBandH; const contentH = Math.max(P.length*rowH, C.length*rowH, 240); const H = topPad + contentH + botPad; const pX = 22, pW = 196, cW = 200, cX = W - 22 - cW, hubW = 132, hubH = 92; const midY = topPad + contentH/2; const colTop = (n) => topPad + (contentH - n*rowH)/2; const pTop = colTop(P.length), cTop = colTop(C.length); const pPos = {}; P.forEach((p,i) => pPos[p.id] = pTop + i*rowH + rowH/2); const cPos = {}; C.forEach((c,i) => cPos[c.consumer.id] = cTop + i*rowH + rowH/2); const hubLx = W/2 - hubW/2, hubRx = W/2 + hubW/2, hubTy = midY - hubH/2; const labelY = topPad - 18; const focus = !allActive && !!sel; let activeP = {}, activeC = {}, activeX = {}, focusEnt = {}; if(!focus){ P.forEach(p => activeP[p.id]=1); C.forEach(c => activeC[c.consumer.id]=1); CX.forEach(x => activeX[x.id]=1); } else if(selKind==="producer"){ activeP[sel]=1; const p = P.find(x=>x.id===sel); (p?p.consumers:[]).forEach(id => activeC[id]=1); if(p) p.sigs.forEach(x => focusEnt[x.sig.entity]=1); } else if(selKind==="consumer"){ activeC[sel]=1; const c = C.find(x=>x.consumer.id===sel); (c?c.fedBy:[]).forEach(id => activeP[id]=1); if(c) c.received.forEach(x => focusEnt[x.sig.entity]=1); } if(focus){ CX.forEach(x => { if(x.appliesTo.indexOf("*")!==-1 || x.appliesTo.some(e => focusEnt[e])) activeX[x.id]=1; }); } const animate = animOn && !reducedMotion(); let s = ''; s += ''; s += ''; s += ''; // context band if(CX.length){ s += 'CONTEXT, systems writing business context into the UNS'; const corL = pX + pW + 16, corR = cX - 16, avail = corR - corL, ncx = CX.length, gap = 10; const cbW = Math.min(150, (avail - (ncx-1)*gap)/ncx); const totalW = ncx*cbW + (ncx-1)*gap; const startX = (corL + corR)/2 - totalW/2; const cbY = 36, cbH = 36; // arrows kept inside the central corridor so they clear both columns CX.forEach((x,i) => { const nx = startX + i*(cbW+gap) + cbW/2, on = !focus || activeX[x.id]; const d = "M" + nx + " " + (cbY+cbH) + " C" + nx + " " + (hubTy-24) + " " + (W/2) + " " + (hubTy-24) + " " + (W/2) + " " + hubTy; s += ''; if(animate && on){ s += ''; } }); CX.forEach((x,i) => { const nx = startX + i*(cbW+gap), on = !focus || activeX[x.id], op = on?1:0.34; const label = x.short || x.name; s += ''; s += '' + svgEscX(label) + ''; s += '+' + x.provides.length + ' fields'; }); } s += 'PRODUCERS'; s += 'UNIFIED NAMESPACE'; s += 'CONSUMERS'; let pulses = ""; P.forEach(p => { const y = pPos[p.id], on = activeP[p.id]; const d = spokePath(pX+pW, y, hubLx, midY); const stroke = focus ? (on?cyan:line) : dimc, sw = focus ? (on?2.6:1) : 1.6, op = focus ? (on?1:0.25) : 0.9; s += ''; if(animate && (!focus || on)){ pulses += ''; } }); C.forEach(c => { const id = c.consumer.id, y = cPos[id], on = activeC[id]; const d = spokePath(hubRx, midY, cX, y); const stroke = focus ? (on?cyan:line) : dimc, sw = focus ? (on?2.6:1) : 1.6, op = focus ? (on?1:0.25) : 0.9; s += ''; if(animate && (!focus || on)){ pulses += ''; } }); s += pulses; s += ''; s += 'UNS'; s += 'MQTT broker'; s += '' + collectSignals().length + ' signals'; P.forEach(p => { const y = pPos[p.id], on = !focus || activeP[p.id], isSel = (selKind==="producer" && sel===p.id); const op = on?1:0.32, boxH=44; s += ''; if(isSel) s += ''; s += ''; s += ''; s += '' + svgEscX((LEVEL_BY_ID[p.node.level]||{badge:"?"}).badge) + ''; s += '' + svgEscX(p.node.name) + ''; s += '' + p.count + ' signal' + (p.count===1?'':'s') + ''; s += ''; }); C.forEach(c => { const id=c.consumer.id, y=cPos[id], on = !focus || activeC[id], isSel=(selKind==="consumer" && sel===id); const op = on?1:0.32, boxH=44; s += ''; if(isSel) s += ''; s += ''; s += '' + svgEscX(c.consumer.name) + ''; s += '' + c.count + ' signal' + (c.count===1?'':'s') + ', ' + svgEscX(c.consumer.kind) + ''; s += ''; }); return s + ''; } function flowDetail(model){ let sel = state.flow.sel, selKind = state.flow.selKind; if(sel && selKind==="producer" && !model.producers.find(x=>x.id===sel)){ sel=null; selKind=null; } if(sel && selKind==="consumer" && !model.consumers.find(x=>x.consumer.id===sel)){ sel=null; selKind=null; } if(!sel){ let h = '

How to read this

The plant publishes once to the Unified Namespace. Each consumer subscribes to the slice it needs, by meaning, not by point to point integration. That is the decoupling a UNS buys you. Select a producer or a consumer to trace its data.

'; h += '
Producers ' + model.producers.length + ' at the ' + esc(model.level) + ' level
Consumers ' + model.consumers.length + ' subscribed
Signals ' + collectSignals().length + ' published
'; h += '

Consumers and what they take

'; model.consumers.forEach(c => { h += '
' + esc(c.consumer.name) + '' + esc(c.consumer.sub) + '' + c.count + ' signals
'; }); return h + '
'; } if(selKind==="producer"){ const p = model.producers.find(x => x.id===sel); if(!p) return ''; let h = '

' + esc(p.node.name) + '

' + esc(levelLabel(p.node.level)) + '
'; h += '

Publishes ' + p.count + ' signals to the UNS. These consumers subscribe to part of it.

'; h += '

Consumed by

'; (p.consumers||[]).forEach(cid => { const c = consumerById(cid); if(!c || c.on===false) return; const n = p.sigs.filter(x => consumerWants(c, x.sig.entity)).length; h += '
' + esc(c.name) + '' + n + ' of these signals

' + esc(c.uses) + '

'; }); if(!(p.consumers||[]).length) h += '

No consumer subscribes to this producer yet.

'; const pent = {}; p.sigs.forEach(x => pent[x.sig.entity]=1); const provs = contextOn().filter(cp => cp.appliesTo.indexOf("*")!==-1 || cp.appliesTo.some(e => pent[e])); if(provs.length){ h += '

Context merged here

'; provs.forEach(cp => { h += '
' + esc(cp.name) + '' + cp.provides.map(ff => ff.replace(/_/g," ")).join(", ") + '
'; }); const ex = p.sigs[0]; h += '

Enriched payload for ' + esc(ex.sig.name) + ', value plus context

'; h += '
' + esc(JSON.stringify(enrichedPayload(ex.node, ex.sig), null, 2)) + '
'; } h += '

Signals published here

'; p.sigs.slice(0,40).forEach(x => { h += '
' + esc(signalTopicMqtt(x.node.id, x.sig)) + '' + esc(entityLabel(x.sig.entity)) + '
'; }); h += '
'; return h; } // consumer const c = model.consumers.find(x => x.consumer.id===sel); if(!c) return ''; const C = c.consumer; let h = '

' + esc(C.name) + '

' + esc(C.kind) + '
'; h += '
How it uses this data

' + esc(C.uses) + '

'; h += '
Subscribes to ' + esc(C.sub) + '
By entity ' + (C.entities.indexOf("*")!==-1 ? "every entity" : C.entities.map(e => esc(entityLabel(e))).join(", ")) + '
'; h += '

Signals it receives (' + c.count + ')

'; c.received.slice(0,60).forEach(x => { h += '
' + esc(signalTopicMqtt(x.node.id, x.sig)) + '' + esc(entityLabel(x.sig.entity)) + '
'; }); if(c.received.length>60) h += '

and ' + (c.received.length-60) + ' more

'; h += '
'; return h; } function renderFlowSection(){ const model = flowModel(); if(state.flow.sel){ const okSel = state.flow.selKind==="producer" ? model.producers.some(p=>p.id===state.flow.sel) : model.consumers.some(c=>c.consumer.id===state.flow.sel); if(!okSel){ state.flow.sel=null; state.flow.selKind=null; } } let h = systemsEditorHtml(); h += '
'; h += '
Group producers by
'; ["area","line","cell"].forEach(l => { h += ''; }); h += '
'; h += '
Display options
'; h += ''; h += ''; h += '
'; if(state.flow.sel) h += '
Focus
'; h += '
'; if(!model.producers.length || !model.consumers.length){ h += '
Nothing to flow yet

Add signals on the Designer tab, or load a scenario, and the producers and consumers will appear here.

'; return h; } if(model.producersTotal && model.producersTotal > model.producers.length){ h += '

Showing the ' + model.producers.length + ' busiest producers of ' + model.producersTotal + ' at this level. Group producers by Area for the full picture.

'; } h += '
' + svgFlow(model) + '
'; h += '
Producer, the plant publishingConsumer, a subscribing systemHighlighted spoke and an arrow mean an active subscriptionA ring marks the node you selectedContext provider, a system enriching the namespace
'; h += '
' + flowDetail(model) + '
'; return h; } /* =================================================================== VISUALIZE tab: data flow, namespace tree, placement =================================================================== */ function renderVisualize(v){ state.viz = state.viz || "flow"; let h = pageIntro( "See the namespace at work", "Four views of the same design you built on the Designer. The data flow shows the plant publishing once while many systems subscribe to the slice each one needs. Use cases show which outcomes this design can serve. The tree is a clean, shareable picture of the whole namespace. The placement shows where this namespace fits in the UMDA architecture.", ["Trace the flow, then focus a producer or consumer", "Check which use cases the design supports", "Download the tree or the placement as SVG for a deck"] ); h += '
View
'; [["flow","Data flow"],["usecases","Use cases"],["tree","Namespace tree"],["placement","Placement"]].forEach(o => { h += ''; }); h += '
'; if(state.viz==="flow"){ h += renderFlowSection(); } else if(state.viz==="usecases"){ h += renderUseCases(); } else if(state.viz==="tree"){ const cs = collectSignals(); let nodes=0, depth=0; walk(state.tree, (n, p, anc) => { nodes++; depth = Math.max(depth, (anc?anc.length:0)); }); h += '

Namespace tree

'; h += '

The same tree you build on the Designer, drawn as one picture for sharing. The Designer is where you edit it. Here you see the whole shape at a glance and can download it as an SVG to drop into a slide or document. This design has ' + nodes + ' nodes, ' + (depth+1) + ' levels deep, and ' + cs.length + ' signals.

'; h += levelKeyHtml(); h += '
' + svgTree(false) + '
'; } else { h += '

Where this namespace sits

'; h += '

The UMDA reference architecture with your design slotted in. The Unified Namespace layer, highlighted, is the part this tool builds. Expand any layer to see the detail that comes from your current design. Download the diagram as an SVG to share the placement.

'; h += placementAccordionHtml(); } v.innerHTML = h; // wire sub-toggle v.querySelectorAll("[data-viz]").forEach(b => b.addEventListener("click", () => { state.viz = b.dataset.viz; render(); })); if(state.viz==="flow"){ wireSystems(v); v.querySelectorAll("[data-flowlevel]").forEach(b => b.addEventListener("click", () => { state.flow.level = b.dataset.flowlevel; state.flow.sel = null; state.flow.selKind = null; render(); })); const an = v.querySelector("#flowAnim"); if(an) an.addEventListener("click", () => { state.flow.animate = !state.flow.animate; render(); }); const cxb = v.querySelector("#flowCtx"); if(cxb) cxb.addEventListener("click", () => { state.flow.context = !state.flow.context; render(); }); const cl1 = v.querySelector("#flowClear"); if(cl1) cl1.addEventListener("click", () => { state.flow.sel=null; state.flow.selKind=null; render(); }); const cl2 = v.querySelector("#flowClear2"); if(cl2) cl2.addEventListener("click", () => { state.flow.sel=null; state.flow.selKind=null; render(); }); v.querySelectorAll("[data-pid]").forEach(g => { const f = () => { const id=g.getAttribute("data-pid"); if(state.flow.sel===id&&state.flow.selKind==="producer"){ state.flow.sel=null; state.flow.selKind=null; } else { state.flow.sel=id; state.flow.selKind="producer"; } render(); }; g.addEventListener("click", f); g.addEventListener("keydown", e => { if(e.key==="Enter"||e.key===" "){ e.preventDefault(); f(); } }); }); v.querySelectorAll("[data-cid]").forEach(g => { const f = () => { const id=g.getAttribute("data-cid"); if(state.flow.sel===id&&state.flow.selKind==="consumer"){ state.flow.sel=null; state.flow.selKind=null; } else { state.flow.sel=id; state.flow.selKind="consumer"; } render(); }; g.addEventListener("click", f); g.addEventListener("keydown", e => { if(e.key==="Enter"||e.key===" "){ e.preventDefault(); f(); } }); }); } else if(state.viz==="usecases"){ v.querySelectorAll("[data-uc]").forEach(b => b.addEventListener("click", () => { state.uc = b.dataset.uc; render(); })); } else if(state.viz==="tree"){ const d = v.querySelector("#dlTree"); if(d) d.addEventListener("click", () => download(safeName()+"-namespace-tree.svg", svgTree(true), "image/svg+xml")); } else { const d = v.querySelector("#dlLayers"); if(d) d.addEventListener("click", () => download(safeName()+"-uns-placement.svg", svgFiveLayer(true), "image/svg+xml")); v.querySelectorAll("[data-place]").forEach(b => b.addEventListener("click", () => { const id = b.dataset.place; state.placeLayer = (state.placeLayer===id ? null : id); render(); })); } } /* ---------- Boot ---------- */ if(typeof document !== "undefined" && document.addEventListener){ document.addEventListener("keydown", function(e){ const k = (e.key||"").toLowerCase(); const t = e.target || {}, tn = t.tagName || ""; const inField = tn==="INPUT" || tn==="TEXTAREA" || tn==="SELECT" || t.isContentEditable; if((e.ctrlKey||e.metaKey) && k==="z" && !inField){ e.preventDefault(); if(e.shiftKey) redo(); else undo(); } else if((e.ctrlKey||e.metaKey) && k==="y" && !inField){ e.preventDefault(); redo(); } }); } loadProjects(); loadBlocks(); if(loadFromHash()) toast("Loaded a shared design"); render(); /* ---------- cross-tool handoff: the CDM Designer left its catalog here via localStorage (same origin). Importing it turns the entities' typed attributes into payload templates per topic — the contract riding the namespace. Key consumed on import/dismiss; stale after an hour. ---------- */ (function(){ var h = null; try{ h = JSON.parse(localStorage.getItem("umda_handoff") || "null"); }catch(e){} if(!h || h.to !== "uns-designer" || !h.payload) return; if(Date.now() - (h.at || 0) > 36e5){ try{ localStorage.removeItem("umda_handoff"); }catch(e){} return; } var bar = document.createElement("div"); bar.className = "handoff-bar"; bar.setAttribute("role", "status"); bar.innerHTML = 'Model received from the ' + esc(h.from || "another designer") + '' + (h.name ? (': ' + esc(h.name)) : '') + ' — import it? Its entities and typed attributes become your payload templates.' + '' + ''; var tabs = document.getElementById("tabs"); tabs.parentNode.insertBefore(bar, tabs); function done(){ try{ localStorage.removeItem("umda_handoff"); }catch(e){} if(bar.parentNode) bar.parentNode.removeChild(bar); } document.getElementById("hbImport").addEventListener("click", function(){ try{ var r = libraryImport(h.payload); state.tab = "entities"; done(); render(); toast("Imported from the " + (h.from || "designer") + ": " + r.addedEnt + " new entit" + (r.addedEnt === 1 ? "y" : "ies") + ", " + r.setTpl + " payload template" + (r.setTpl === 1 ? "" : "s")); }catch(err){ toast("Import failed: " + err.message); } }); document.getElementById("hbLater").addEventListener("click", done); })(); /* ---------- shared plant workspace: one plant identity across the UNS / CDM / Agent designers. Each tool records its status + a Blueprint fragment under localStorage `umda_plant` whenever it saves; any tool can assemble and download the combined UMDA Blueprint. This tool contributes the Unified Namespace section (naming standard + topic stats). ---------- */ const WS_TOOL = "uns"; function wsRead(){ try{ const w = JSON.parse(localStorage.getItem("umda_plant") || "null"); return (w && typeof w === "object") ? w : null; }catch(e){ return null; } } function wsWrite(w){ try{ localStorage.setItem("umda_plant", JSON.stringify(w)); }catch(e){} } function wsReady(){ return collectSignals().length > 0 && !validateDesign().some(f => f.sev === "high"); } function wsDesignName(){ return state.tree.name || ""; } function wsFragment(){ const sigs = collectSignals().length, fails = validateDesign(); return "## Unified Namespace\n\n" + "Design: **" + (state.tree.name || "unnamed") + "** — " + sigs + " topic" + (sigs === 1 ? "" : "s") + " across " + allEntities().length + " catalog entities. " + (fails.length ? (fails.length + " open design finding" + (fails.length === 1 ? "" : "s") + " (see the Validate tab).") : "All design checks pass.") + "\n\n" + namingStandardMd(); } let _wsLast = 0; function wsUpdate(){ const w = wsRead(); if(!w || !w.name) return; const now = Date.now(); if(now - _wsLast < 1500) return; _wsLast = now; try{ w.tools = w.tools || {}; w.tools[WS_TOOL] = { status: wsReady() ? "ready" : "in progress", design: wsDesignName(), at: now, fragment: wsFragment() }; wsWrite(w); wsStrip(); }catch(e){} } function wsBlueprintMd(){ const w = wsRead(); if(!w || !w.name) return ""; const order = [["edge","Edge Intelligence Hub","Edge Node Planner"],["uns","Unified Namespace","UNS Designer"],["cdm","Common Data Model","CDM Designer"],["agent","AI Agents & Router","Agent Designer"]]; let md = "# UMDA Blueprint — " + w.name + "\n\n"; md += "Generated " + new Date().toISOString().slice(0,10) + " by the UMDA design tools (https://umda.info/tools.html). Sensor to agent, end to end: the edge that earns the data, the namespace that names it, the model that types it, and the agents that consume it.\n\n"; md += "| Layer | Tool | Status | Design |\n|---|---|---|---|\n"; order.forEach(o => { const r = w.tools && w.tools[o[0]]; md += "| " + o[1] + " | " + o[2] + " | " + (r ? (r.status === "ready" ? "Ready" : "In progress") : "Not started") + " | " + ((r && r.design) || "—") + " |\n"; }); order.forEach(o => { const r = w.tools && w.tools[o[0]]; md += "\n---\n\n" + (r && r.fragment ? r.fragment : "## " + o[1] + "\n\n_Not designed yet: open the " + o[2] + " and save a design while this workspace is active._") + "\n"; }); return md; } function wsStrip(){ let bar = document.getElementById("plantStrip"); if(!bar){ bar = document.createElement("div"); bar.id = "plantStrip"; bar.className = "plant-strip"; const tabs = document.getElementById("tabs"); tabs.parentNode.insertBefore(bar, tabs); } const w = wsRead(); const chip = (k, lbl) => { const r = w && w.tools && w.tools[k]; const s = !r ? "—" : (r.status === "ready" ? "✓" : "in progress"); return '' + lbl + ' ' + s + ''; }; if(!w || !w.name){ bar.innerHTML = 'Shared plant workspace:One name across the UNS, CDM and Agent designers — each shows the others’ progress, and any of them can export the combined UMDA Blueprint.'; bar.querySelector("#psStart").addEventListener("click", () => { const v = bar.querySelector("#psName").value.trim(); if(!v){ toast("Give the plant a name first"); return; } wsWrite({ name: v, tools: {} }); _wsLast = 0; wsUpdate(); wsStrip(); toast("Workspace started: the designers now all contribute to “" + v + "”"); }); bar.querySelector("#psName").addEventListener("keydown", (e) => { if(e.key === "Enter") bar.querySelector("#psStart").click(); }); return; } bar.innerHTML = 'Plant:' + esc(w.name) + '' + chip("uns","UNS") + chip("cdm","CDM") + chip("agent","Agents") + chip("edge","Edge") + ''; bar.querySelector("#psBlueprint").addEventListener("click", () => { const md = wsBlueprintMd(); if(!md) return; download((w.name.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"") || "plant") + "-umda-blueprint.md", md, "text/markdown"); toast("Blueprint downloaded: the whole plant design in one document"); }); bar.querySelector("#psRename").addEventListener("click", () => { const v = prompt("Plant name (empty removes the shared workspace):", w.name); if(v === null) return; const nv = v.trim(); if(!nv){ try{ localStorage.removeItem("umda_plant"); }catch(e){} wsStrip(); toast("Workspace removed"); return; } w.name = nv; wsWrite(w); wsStrip(); }); } wsUpdate(); wsStrip();