From aff4bbc6a4f4f21e8a61a89f2fbd1ec1d38fa32d Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:51:43 -0400 Subject: [PATCH 01/10] Add published color states and emission channel schema --- schema/taxonomy.schema.json | 22 ++ schema/ulc.schema.json | 330 ++++++++++++++++++ .../completeness/completeness_drift_test.go | 2 + .../validate/color_tuning_schema_test.go | 184 ++++++++++ 4 files changed, 538 insertions(+) create mode 100644 tools/validator/internal/validate/color_tuning_schema_test.go diff --git a/schema/taxonomy.schema.json b/schema/taxonomy.schema.json index dedfe2a..a0a5827 100644 --- a/schema/taxonomy.schema.json +++ b/schema/taxonomy.schema.json @@ -1595,6 +1595,28 @@ "self_diagnostic", "networked" ] + }, + + "ColorStateRole": { + "title": "Color state role", + "description": "The published role of a luminaire state. Range ends describe the white range; a setting names a preset; a dim level describes output at a stated percent.", + "type": "string", + "enum": [ + "range_low_end", + "range_high_end", + "setting", + "dim_level" + ] + }, + + "EmissionChannelKind": { + "title": "Emission channel kind", + "description": "Whether an emitter channel produces white light or a color. A channel describes an emitter rather than a state of the complete luminaire.", + "type": "string", + "enum": [ + "white", + "color" + ] } } } diff --git a/schema/ulc.schema.json b/schema/ulc.schema.json index c9d21f6..78345b3 100644 --- a/schema/ulc.schema.json +++ b/schema/ulc.schema.json @@ -86,6 +86,11 @@ "$ref": "#/$defs/AlphaOpicMetrics" }, + "color_tuning": { + "description": "Published states and emitter channels beside the tested baseline. States reference the baseline by role and never repeat it.", + "$ref": "#/$defs/ColorTuning" + }, + "flicker_measurements": { "description": "Temporal light artefact (TLA) measurements per LM-90-20 and metric values per CIE TN 006 and IEEE 1789.", "$ref": "#/$defs/FlickerMeasurements" @@ -203,6 +208,27 @@ "source_files" ], + "if": { + "properties": { + "configuration": { + "properties": { + "tested_axes": { + "properties": { + "color_tunability": { + "const": "static_white" + } + } + } + } + } + } + }, + "then": { + "properties": { + "color_tuning": false + } + }, + "$defs": { "Index": { @@ -1215,6 +1241,310 @@ } }, + "ColorTuning": { + "title": "Published color tuning", + "description": "The top-level measurement blocks remain the tested baseline. Only other luminaire states appear in states. End roles occur at most once, counting the referenced baseline.", + "type": "object", + "properties": { + "baseline_role": { + "$ref": "taxonomy.schema.json#/$defs/ColorStateRole" + }, + "baseline_label": { + "type": "string", + "minLength": 1 + }, + "baseline_dim_level_percent": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 100 + }, + "states": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/ColorState" + }, + "allOf": [ + { + "contains": { + "properties": { + "role": { + "const": "range_low_end" + } + }, + "required": [ + "role" + ] + }, + "minContains": 0, + "maxContains": 1 + }, + { + "contains": { + "properties": { + "role": { + "const": "range_high_end" + } + }, + "required": [ + "role" + ] + }, + "minContains": 0, + "maxContains": 1 + } + ] + }, + "channels": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/EmissionChannel" + } + } + }, + "required": [ + "baseline_role" + ], + "anyOf": [ + { + "required": [ + "states" + ] + }, + { + "required": [ + "channels" + ] + } + ], + "allOf": [ + { + "if": { + "properties": { + "baseline_role": { + "const": "dim_level" + } + }, + "required": [ + "baseline_role" + ] + }, + "then": { + "required": [ + "baseline_dim_level_percent" + ] + } + }, + { + "if": { + "properties": { + "baseline_role": { + "const": "setting" + } + }, + "required": [ + "baseline_role" + ] + }, + "then": { + "not": { + "required": [ + "baseline_dim_level_percent" + ] + } + } + }, + { + "if": { + "properties": { + "baseline_role": { + "const": "range_low_end" + } + }, + "required": [ + "baseline_role" + ] + }, + "then": { + "properties": { + "states": { + "not": { + "contains": { + "properties": { + "role": { + "const": "range_low_end" + } + }, + "required": [ + "role" + ] + } + } + } + } + } + }, + { + "if": { + "properties": { + "baseline_role": { + "const": "range_high_end" + } + }, + "required": [ + "baseline_role" + ] + }, + "then": { + "properties": { + "states": { + "not": { + "contains": { + "properties": { + "role": { + "const": "range_high_end" + } + }, + "required": [ + "role" + ] + } + } + } + } + } + } + ] + }, + + "ColorState": { + "title": "Published luminaire color state", + "type": "object", + "properties": { + "state_id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "role": { + "$ref": "taxonomy.schema.json#/$defs/ColorStateRole" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "dim_level_percent": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 100 + }, + "colorimetry": { + "allOf": [ + { + "$ref": "#/$defs/Colorimetry" + }, + { + "anyOf": [ + { + "required": [ + "nominal_cct_k" + ] + }, + { + "required": [ + "measured_cct_k" + ] + } + ] + } + ] + }, + "alpha_opic_metrics": { + "$ref": "#/$defs/AlphaOpicMetrics" + }, + "lumens": { + "$ref": "#/$defs/ProvenancedNumber" + }, + "efficacy_lm_per_w": { + "$ref": "#/$defs/ProvenancedNumber" + } + }, + "required": [ + "state_id", + "role", + "colorimetry" + ], + "allOf": [ + { + "if": { + "properties": { + "role": { + "const": "dim_level" + } + }, + "required": [ + "role" + ] + }, + "then": { + "required": [ + "dim_level_percent" + ] + } + }, + { + "if": { + "properties": { + "role": { + "const": "setting" + } + }, + "required": [ + "role" + ] + }, + "then": { + "not": { + "required": [ + "dim_level_percent" + ] + } + } + } + ] + }, + + "EmissionChannel": { + "title": "Emission channel", + "type": "object", + "properties": { + "channel_id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "label": { + "type": "string", + "minLength": 1 + }, + "kind": { + "$ref": "taxonomy.schema.json#/$defs/EmissionChannelKind" + }, + "peak_wavelength_nm": { + "$ref": "#/$defs/ProvenancedNumber" + }, + "lumens": { + "$ref": "#/$defs/ProvenancedNumber" + }, + "colorimetry": { + "$ref": "#/$defs/Colorimetry" + } + }, + "required": [ + "channel_id", + "kind" + ] + }, + "Colorimetry": { "title": "Colorimetry", "description": "Color metrics including CCT, Duv, CRI, and TM-30 color rendition.", diff --git a/tools/validator/internal/completeness/completeness_drift_test.go b/tools/validator/internal/completeness/completeness_drift_test.go index d1a5692..f043ae5 100644 --- a/tools/validator/internal/completeness/completeness_drift_test.go +++ b/tools/validator/internal/completeness/completeness_drift_test.go @@ -396,6 +396,8 @@ func rubricTaxonomies() map[string]bool { // is ever both rubric-covered and allowlisted, keeping this registry a conscious record // of what is deliberately NOT on a roadmap. var descriptiveAllowlist = map[string]bool{ + "ColorStateRole": true, + "EmissionChannelKind": true, // Record + provenance + file plumbing. "RecordStatus": true, "ProvenanceSource": true, diff --git a/tools/validator/internal/validate/color_tuning_schema_test.go b/tools/validator/internal/validate/color_tuning_schema_test.go new file mode 100644 index 0000000..e9e43f8 --- /dev/null +++ b/tools/validator/internal/validate/color_tuning_schema_test.go @@ -0,0 +1,184 @@ +package validate + +import ( + "encoding/json" + "path/filepath" + "reflect" + "testing" + + embedded "github.com/ulcspec/ULC/schema" + "github.com/ulcspec/ULC/tools/validator/internal/findings" +) + +// Pin the schema keywords that make the referenced baseline unambiguous. +func TestColorTuningSchemaShape(t *testing.T) { + var doc map[string]any + if err := json.Unmarshal(embedded.ULCSchemaJSON, &doc); err != nil { + t.Fatal(err) + } + defs := doc["$defs"].(map[string]any) + tuning := defs["ColorTuning"].(map[string]any) + for name, want := range map[string][]any{ + "ColorTuning": {"baseline_role"}, + "ColorState": {"state_id", "role", "colorimetry"}, + "EmissionChannel": {"channel_id", "kind"}, + } { + if got := defs[name].(map[string]any)["required"]; !reflect.DeepEqual(got, want) { + t.Errorf("%s required = %v, want %v", name, got, want) + } + } + props := tuning["properties"].(map[string]any) + for _, key := range []string{"states", "channels"} { + if props[key].(map[string]any)["minItems"] != float64(1) { + t.Errorf("%s must require a nonempty array", key) + } + } + limits := props["states"].(map[string]any)["allOf"].([]any) + if len(limits) != 2 { + t.Fatalf("end-role constraints = %d, want 2", len(limits)) + } + for i, role := range []string{"range_low_end", "range_high_end"} { + limit := limits[i].(map[string]any) + if limit["minContains"] != float64(0) || limit["maxContains"] != float64(1) { + t.Errorf("%s must allow zero or one listed end", role) + } + contains := limit["contains"].(map[string]any) + if got := contains["properties"].(map[string]any)["role"].(map[string]any)["const"]; got != role { + t.Errorf("contains role = %v, want %s", got, role) + } + } + then := doc["then"].(map[string]any)["properties"].(map[string]any) + if got, exists := then["color_tuning"]; !exists || got != false { + t.Fatalf("static-white conditional must use false schema at /color_tuning, got %v", got) + } + color := defs["ColorState"].(map[string]any)["properties"].(map[string]any)["colorimetry"].(map[string]any) + if len(color["allOf"].([]any)) != 2 { + t.Fatal("state colorimetry must compose Colorimetry and the CCT requirement") + } +} + +func TestColorTuningSchema(t *testing.T) { + root := repoRoot(t) + v, err := NewValidator(filepath.Join(root, "schema")) + if err != nil { + t.Fatal(err) + } + state := func(role string) map[string]any { + return map[string]any{"state_id": "published-state", "role": role, "colorimetry": map[string]any{"nominal_cct_k": "2700"}} + } + tests := []struct { + name string + change func(map[string]any, map[string]any) + valid bool + }{ + {"setting and one published end", func(_ map[string]any, _ map[string]any) {}, true}, + {"settings only needs no end", func(_ map[string]any, b map[string]any) { b["states"] = []any{state("setting")} }, true}, + {"channels only", func(_ map[string]any, b map[string]any) { + delete(b, "states") + b["channels"] = []any{map[string]any{"channel_id": "red", "kind": "color"}} + }, true}, + {"static white refuses block", func(r map[string]any, _ map[string]any) { + r["configuration"].(map[string]any)["tested_axes"].(map[string]any)["color_tunability"] = "static_white" + }, false}, + {"absent capability refuses block", func(r map[string]any, _ map[string]any) { + delete(r["configuration"].(map[string]any)["tested_axes"].(map[string]any), "color_tunability") + }, false}, + {"absent axes refuses block", func(r map[string]any, _ map[string]any) { delete(r["configuration"].(map[string]any), "tested_axes") }, false}, + {"baseline role required", func(_ map[string]any, b map[string]any) { delete(b, "baseline_role") }, false}, + {"rows required", func(_ map[string]any, b map[string]any) { delete(b, "states") }, false}, + {"empty states refused", func(_ map[string]any, b map[string]any) { b["states"] = []any{} }, false}, + {"empty channels refused", func(_ map[string]any, b map[string]any) { b["channels"] = []any{} }, false}, + {"baseline end cannot repeat", func(_ map[string]any, b map[string]any) { b["baseline_role"] = "range_low_end" }, false}, + {"duplicate low end", func(_ map[string]any, b map[string]any) { + b["states"] = []any{state("range_low_end"), state("range_low_end")} + }, false}, + {"duplicate high end", func(_ map[string]any, b map[string]any) { + b["states"] = []any{state("range_high_end"), state("range_high_end")} + }, false}, + {"dim baseline needs percent", func(_ map[string]any, b map[string]any) { b["baseline_role"] = "dim_level" }, false}, + {"dim baseline with percent", func(_ map[string]any, b map[string]any) { + b["baseline_role"] = "dim_level" + b["baseline_dim_level_percent"] = 50 + }, true}, + {"setting baseline forbids percent", func(_ map[string]any, b map[string]any) { b["baseline_dim_level_percent"] = 100 }, false}, + {"end baseline allows percent", func(_ map[string]any, b map[string]any) { + b["baseline_role"] = "range_high_end" + b["baseline_dim_level_percent"] = 100 + }, true}, + {"dim state needs percent", func(_ map[string]any, b map[string]any) { b["states"] = []any{state("dim_level")} }, false}, + {"dim state with percent", func(_ map[string]any, b map[string]any) { + s := state("dim_level") + s["dim_level_percent"] = 10 + b["states"] = []any{s} + }, true}, + {"setting state forbids percent", func(_ map[string]any, b map[string]any) { + s := state("setting") + s["dim_level_percent"] = 10 + b["states"] = []any{s} + }, false}, + {"end state allows percent", func(_ map[string]any, b map[string]any) { + s := state("range_low_end") + s["dim_level_percent"] = 1 + b["states"] = []any{s} + }, true}, + {"zero percent refused", func(_ map[string]any, b map[string]any) { + s := state("dim_level") + s["dim_level_percent"] = 0 + b["states"] = []any{s} + }, false}, + {"percent over 100 refused", func(_ map[string]any, b map[string]any) { + s := state("dim_level") + s["dim_level_percent"] = 101 + b["states"] = []any{s} + }, false}, + {"state id required", func(_ map[string]any, b map[string]any) { + s := state("setting") + delete(s, "state_id") + b["states"] = []any{s} + }, false}, + {"state id slug", func(_ map[string]any, b map[string]any) { + s := state("setting") + s["state_id"] = "Bad ID" + b["states"] = []any{s} + }, false}, + {"state role required", func(_ map[string]any, b map[string]any) { + s := state("setting") + delete(s, "role") + b["states"] = []any{s} + }, false}, + {"state colorimetry required", func(_ map[string]any, b map[string]any) { + s := state("setting") + delete(s, "colorimetry") + b["states"] = []any{s} + }, false}, + {"state CCT required", func(_ map[string]any, b map[string]any) { + s := state("setting") + s["colorimetry"] = map[string]any{} + b["states"] = []any{s} + }, false}, + {"rated off-bin CCT", func(_ map[string]any, b map[string]any) { + s := state("setting") + s["colorimetry"] = map[string]any{"measured_cct_k": map[string]any{"value": 1850, "value_type": "rated"}} + b["states"] = []any{s} + }, true}, + {"channel id required", func(_ map[string]any, b map[string]any) { b["channels"] = []any{map[string]any{"kind": "white"}} }, false}, + {"channel kind required", func(_ map[string]any, b map[string]any) { b["channels"] = []any{map[string]any{"channel_id": "white"}} }, false}, + {"channel kind enum", func(_ map[string]any, b map[string]any) { + b["channels"] = []any{map[string]any{"channel_id": "white", "kind": "other"}} + }, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := loadOrFail(t, filepath.Join(root, "examples", "erco-quintessence-30416-023.ulc")).(map[string]any) + r["configuration"].(map[string]any)["tested_axes"].(map[string]any)["color_tunability"] = "tunable_white" + b := map[string]any{"baseline_role": "setting", "states": []any{state("range_low_end")}} + r["color_tuning"] = b + tt.change(r, b) + report := findings.NewReport() + v.Validate(r, report) + if got := !report.HasErrors(); got != tt.valid { + t.Fatalf("valid = %v, want %v: %+v", got, tt.valid, report.Findings) + } + }) + } +} From fffd444a3f940ec36d5071d3269dcd85cd878229 Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:53:50 -0400 Subject: [PATCH 02/10] Resolve published color states and validate cross-field consistency --- tools/validator/cmd/ulc/color_tuning_test.go | 44 ++++ tools/validator/cmd/ulc/main.go | 11 + .../validator/internal/colortuning/states.go | 187 ++++++++++++++++ .../internal/colortuning/states_test.go | 211 ++++++++++++++++++ tools/validator/internal/findings/findings.go | 3 + 5 files changed, 456 insertions(+) create mode 100644 tools/validator/cmd/ulc/color_tuning_test.go create mode 100644 tools/validator/internal/colortuning/states.go create mode 100644 tools/validator/internal/colortuning/states_test.go diff --git a/tools/validator/cmd/ulc/color_tuning_test.go b/tools/validator/cmd/ulc/color_tuning_test.go new file mode 100644 index 0000000..a3a6ab0 --- /dev/null +++ b/tools/validator/cmd/ulc/color_tuning_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/ulcspec/ULC/tools/validator/internal/findings" + "github.com/ulcspec/ULC/tools/validator/internal/index" +) + +func TestValidateColorTuningAfterNumberNormalization(t *testing.T) { + raw, err := os.ReadFile(exampleRecord(t, "erco-quintessence-30416-023.ulc")) + if err != nil { + t.Fatal(err) + } + decoded, err := decodeStrict(raw) + if err != nil { + t.Fatal(err) + } + normalized, err := normalizeNumbers(decoded) + if err != nil { + t.Fatal(err) + } + r := normalized.(map[string]any) + r["configuration"].(map[string]any)["tested_axes"].(map[string]any)["color_tunability"] = "multichannel" + r["configuration"].(map[string]any)["tested_conditions"].(map[string]any)["nominal_cct_at_test"] = "3000" + r["color_tuning"] = map[string]any{"baseline_role": "setting", "states": []any{map[string]any{"state_id": "baseline-copy", "role": "setting", "colorimetry": map[string]any{"measured_cct_k": map[string]any{"value": 3000, "value_type": "rated"}}}}} + r["index"] = index.Build(r) + body, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "repeated.ulc") + if err := os.WriteFile(path, body, 0600); err != nil { + t.Fatal(err) + } + out, code := captureStdout(t, func() int { return runValidate([]string{path, "--json"}) }) + if code != 1 || !strings.Contains(out, string(findings.CodeColorTuningInconsistent)) || !strings.Contains(out, "V5:") { + t.Fatalf("exit = %d, output = %s", code, out) + } +} diff --git a/tools/validator/cmd/ulc/main.go b/tools/validator/cmd/ulc/main.go index b11ab5c..c3d1588 100644 --- a/tools/validator/cmd/ulc/main.go +++ b/tools/validator/cmd/ulc/main.go @@ -26,6 +26,7 @@ import ( "time" "github.com/ulcspec/ULC/tools/validator/internal/achievements" + "github.com/ulcspec/ULC/tools/validator/internal/colortuning" "github.com/ulcspec/ULC/tools/validator/internal/completeness" "github.com/ulcspec/ULC/tools/validator/internal/findings" "github.com/ulcspec/ULC/tools/validator/internal/index" @@ -244,6 +245,8 @@ USAGE return 1 } + reportColorTuning(recordMap, report) + // 2. Builder parity. built := index.Build(recordMap) if missing := index.MissingRequiredKeys(built); len(missing) > 0 { @@ -703,6 +706,7 @@ USAGE continue } v.Validate(rawTree, report) + reportColorTuning(res.Record, report) completeness.Report(res.Record, report) achievements.Report(res.Record, report) report.Finalize() @@ -1089,3 +1093,10 @@ func writeRecord(path string, record index.Record) error { } return nil } + +// reportColorTuning runs after schema validation on the normalized record. +func reportColorTuning(record map[string]any, report *findings.Report) { + for _, problem := range colortuning.Problems(record) { + report.AddError(findings.CodeColorTuningInconsistent, problem.Path, problem.Message) + } +} diff --git a/tools/validator/internal/colortuning/states.go b/tools/validator/internal/colortuning/states.go new file mode 100644 index 0000000..76b91c6 --- /dev/null +++ b/tools/validator/internal/colortuning/states.go @@ -0,0 +1,187 @@ +// Package colortuning resolves published luminaire states without duplicating +// the tested baseline. It has no dependency on other internal packages. +package colortuning + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" +) + +// State is a resolved luminaire state. Maps reference the authoritative blocks +// in the record, including the spectrum within Colorimetry; no values are merged. +type State struct { + ID string + Role string + Label string + DimLevelPercent float64 + CCT float64 + HasCCT bool + Baseline bool + Path string + Colorimetry map[string]any + AlphaOpicMetrics map[string]any + Lumens map[string]any + Efficacy map[string]any +} + +// States returns the tested baseline first, followed by published states in +// record order. An absent dim level means full output (100 percent). +func States(record map[string]any) []State { + tuning := object(record, "color_tuning") + color := object(record, "colorimetry") + cct, hasCCT := stateCCT(color) + cfg := object(object(record, "configuration"), "tested_conditions") + if nominal, ok := numeric(cfg["nominal_cct_at_test"]); ok { + cct, hasCCT = nominal, true + } + baseline := State{ + Role: text(tuning, "baseline_role"), Label: text(tuning, "baseline_label"), + DimLevelPercent: dim(tuning, "baseline_dim_level_percent"), CCT: cct, HasCCT: hasCCT, + Baseline: true, Colorimetry: color, AlphaOpicMetrics: object(record, "alpha_opic_metrics"), + Lumens: object(object(record, "photometry"), "total_luminous_flux_lm"), + Efficacy: object(object(record, "photometry"), "luminaire_efficacy_lm_per_w"), + } + out := []State{baseline} + rows, _ := tuning["states"].([]any) + for i, raw := range rows { + row, ok := raw.(map[string]any) + if !ok { + continue + } + color := object(row, "colorimetry") + cct, hasCCT := stateCCT(color) + out = append(out, State{ID: text(row, "state_id"), Role: text(row, "role"), Label: text(row, "label"), + DimLevelPercent: dim(row, "dim_level_percent"), CCT: cct, HasCCT: hasCCT, + Path: fmt.Sprintf("/color_tuning/states/%d", i), Colorimetry: color, + AlphaOpicMetrics: object(row, "alpha_opic_metrics"), Lumens: object(row, "lumens"), Efficacy: object(row, "efficacy_lm_per_w")}) + } + return out +} + +// Ends returns the low and high end, including a referenced baseline end. +// Resolved is true only when both ends carry a CCT under the single CCT rule. +func Ends(record map[string]any) (low, high State, resolved bool) { + for _, s := range States(record) { + switch s.Role { + case "range_low_end": + low = s + case "range_high_end": + high = s + } + } + return low, high, low.HasCCT && high.HasCCT +} + +// Problem is one cross-field inconsistency. Schema validation owns field shapes. +type Problem struct { + Path string + Message string +} + +// Problems applies V1 to V6 to the same resolved states used by every reader. +func Problems(record map[string]any) []Problem { + tuning := object(record, "color_tuning") + if tuning == nil { + return nil + } + var out []Problem + add := func(path, msg string) { out = append(out, Problem{path, msg}) } + for _, pair := range []struct{ key, id string }{{"states", "state_id"}, {"channels", "channel_id"}} { + seen := map[string]bool{} + rows, _ := tuning[pair.key].([]any) + for i, raw := range rows { + row, _ := raw.(map[string]any) + id := text(row, pair.id) + if id == "" { + continue + } + if seen[id] { + add(fmt.Sprintf("/color_tuning/%s/%d/%s", pair.key, i, pair.id), "V1: repeated "+pair.id) + } + seen[id] = true + } + } + states := States(record) + hasLow, hasHigh := false, false + for _, s := range states { + hasLow = hasLow || s.Role == "range_low_end" + hasHigh = hasHigh || s.Role == "range_high_end" + } + if hasLow != hasHigh { + add("/color_tuning", "V2: a published range must have both ends, counting the baseline") + } + low, high, resolved := Ends(record) + if resolved && low.CCT >= high.CCT { + add("/color_tuning", "V3: the low-end CCT must be below the high-end CCT") + } + capability := text(object(object(record, "configuration"), "tested_axes"), "color_tunability") + if resolved && (capability == "tunable_white" || capability == "dim_to_warm") { + for _, s := range states { + if s.HasCCT && (s.CCT < low.CCT || s.CCT > high.CCT) { + path := s.Path + if s.Baseline { + path = "/color_tuning/baseline_role" + } + add(path, "V4: a luminaire state is outside the published white range") + } + } + } + for i, a := range states { + for _, b := range states[i+1:] { + if !a.HasCCT || !b.HasCCT || a.CCT != b.CCT || a.DimLevelPercent != b.DimLevelPercent { + continue + } + if strings.EqualFold(a.Label, b.Label) { + add(b.Path, "V5: two states share CCT, dim level, and case-folded label") + } + if a.Label == "" || b.Label == "" { + add(b.Path, "V6: states sharing CCT and dim level must each have a label") + } + } + } + return out +} + +func object(m map[string]any, key string) map[string]any { v, _ := m[key].(map[string]any); return v } +func text(m map[string]any, key string) string { v, _ := m[key].(string); return v } +func dim(m map[string]any, key string) float64 { + if v, ok := numeric(m[key]); ok { + return v + } + return 100 +} +func stateCCT(color map[string]any) (float64, bool) { + if v, ok := numeric(color["nominal_cct_k"]); ok { + return v, true + } + return numeric(object(color, "measured_cct_k")["value"]) +} +func numeric(raw any) (float64, bool) { + var v float64 + switch n := raw.(type) { + case string: + parsed, err := strconv.ParseFloat(n, 64) + if err != nil { + return 0, false + } + v = parsed + case json.Number: + parsed, err := n.Float64() + if err != nil { + return 0, false + } + v = parsed + case float64: + v = n + case int64: + v = float64(n) + case int: + v = float64(n) + default: + return 0, false + } + return v, !math.IsNaN(v) && !math.IsInf(v, 0) +} diff --git a/tools/validator/internal/colortuning/states_test.go b/tools/validator/internal/colortuning/states_test.go new file mode 100644 index 0000000..b21953c --- /dev/null +++ b/tools/validator/internal/colortuning/states_test.go @@ -0,0 +1,211 @@ +package colortuning + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func fixture() map[string]any { + return map[string]any{ + "configuration": map[string]any{"tested_axes": map[string]any{"color_tunability": "tunable_white"}, "tested_conditions": map[string]any{"nominal_cct_at_test": "3000"}}, + "colorimetry": map[string]any{"nominal_cct_k": "3500", "measured_cct_k": map[string]any{"value": 2985.0}}, + "color_tuning": map[string]any{"baseline_role": "setting", "states": []any{testState("warm", "range_low_end", "2700"), testState("cool", "range_high_end", "6500")}}, + } +} +func testState(id, role, cct string) map[string]any { + return map[string]any{"state_id": id, "role": role, "colorimetry": map[string]any{"nominal_cct_k": cct}} +} +func testRows(r map[string]any) []any { return object(r, "color_tuning")["states"].([]any) } + +func TestStatesResolveBaselineFirstAndKeepAuthoritativeBlocks(t *testing.T) { + r := fixture() + spectrum := map[string]any{"values": []any{0.2, 0.4}} + object(r, "colorimetry")["spectral_power_distribution"] = spectrum + r["alpha_opic_metrics"] = map[string]any{"melanopic_der": map[string]any{"value": 0.5}} + r["photometry"] = map[string]any{"total_luminous_flux_lm": map[string]any{"value": 1200}, "luminaire_efficacy_lm_per_w": map[string]any{"value": 90}} + warm := testRows(r)[0].(map[string]any) + warm["label"] = "Warm" + warm["dim_level_percent"] = 10.0 + warm["alpha_opic_metrics"] = map[string]any{"melanopic_der": map[string]any{"value": 0.3}} + warm["lumens"] = map[string]any{"value": 100} + warm["efficacy_lm_per_w"] = map[string]any{"value": 80} + states := States(r) + if len(states) != 3 { + t.Fatalf("states = %v", states) + } + if !states[0].Baseline || states[1].Baseline || states[0].CCT != 3000 || states[0].DimLevelPercent != 100 { + t.Fatalf("baseline = %+v", states[0]) + } + if states[1].ID != "warm" || states[1].Role != "range_low_end" || states[1].Label != "Warm" || states[1].DimLevelPercent != 10 || states[2].ID != "cool" { + t.Fatalf("published order and details = %+v", states) + } + if !reflect.DeepEqual(states[1].AlphaOpicMetrics, warm["alpha_opic_metrics"]) || !reflect.DeepEqual(states[1].Lumens, warm["lumens"]) || !reflect.DeepEqual(states[1].Efficacy, warm["efficacy_lm_per_w"]) { + t.Fatal("state values are not its own blocks") + } + states[0].Colorimetry["pointer_check"] = true + if object(r, "colorimetry")["pointer_check"] != true { + t.Fatal("resolver copied baseline colorimetry") + } + states[1].Colorimetry["pointer_check"] = true + if object(warm, "colorimetry")["pointer_check"] != true { + t.Fatal("resolver copied listed colorimetry") + } + if !reflect.DeepEqual(states[0].Colorimetry["spectral_power_distribution"], spectrum) { + t.Fatal("baseline spectrum lost") + } + if states[1].Colorimetry["spectral_power_distribution"] != nil { + t.Fatal("baseline spectrum leaked into listed state") + } + if states[0].Lumens["value"] != 1200 || states[0].Efficacy["value"] != 90 { + t.Fatal("baseline photometry lost") + } +} + +func TestStatesUseOneCCTRule(t *testing.T) { + r := fixture() + for _, tt := range []struct { + name string + change func() + want float64 + }{ + {"tested bin wins", func() {}, 3000}, + {"colorimetry bin fallback", func() { delete(object(object(r, "configuration"), "tested_conditions"), "nominal_cct_at_test") }, 3500}, + {"measured fallback", func() { delete(object(r, "colorimetry"), "nominal_cct_k") }, 2985}, + } { + t.Run(tt.name, func(t *testing.T) { + tt.change() + if got := States(r)[0].CCT; got != tt.want { + t.Fatalf("CCT = %v, want %v", got, tt.want) + } + }) + } + low := testRows(r)[0].(map[string]any) + low["colorimetry"] = map[string]any{"measured_cct_k": map[string]any{"value": json.Number("1850"), "value_type": "rated"}} + a, b, ok := Ends(r) + if !ok || a.CCT != 1850 || b.CCT != 6500 { + t.Fatalf("off-bin ends = %+v %+v %v", a, b, ok) + } + tuning := object(r, "color_tuning") + tuning["baseline_role"] = "range_high_end" + tuning["states"] = []any{low} + a, b, ok = Ends(r) + if !ok || !b.Baseline || a.CCT != 1850 || b.CCT != 2985 { + t.Fatalf("baseline high end = %+v %+v %v", a, b, ok) + } +} + +func TestProblemsV1ToV6AndAcceptedCases(t *testing.T) { + tests := []struct { + name, code string + change func(map[string]any) + }{ + {"valid white range", "", func(map[string]any) {}}, + {"duplicate state id", "V1", func(r map[string]any) { testRows(r)[1].(map[string]any)["state_id"] = "warm" }}, + {"duplicate channel id", "V1", func(r map[string]any) { + object(r, "color_tuning")["channels"] = []any{map[string]any{"channel_id": "red"}, map[string]any{"channel_id": "red"}} + }}, + {"lone end", "V2", func(r map[string]any) { object(r, "color_tuning")["states"] = testRows(r)[:1] }}, + {"reversed range", "V3", func(r map[string]any) { + object(testRows(r)[0].(map[string]any), "colorimetry")["nominal_cct_k"] = "6500" + }}, + {"setting outside range", "V4", func(r map[string]any) { + object(r, "color_tuning")["states"] = append(testRows(r), testState("outside", "setting", "7000")) + }}, + {"baseline outside range", "V4", func(r map[string]any) { + object(object(r, "configuration"), "tested_conditions")["nominal_cct_at_test"] = "7000" + }}, + {"repeated baseline", "V5", func(r map[string]any) { + object(r, "color_tuning")["states"] = append(testRows(r), testState("copy", "setting", "3000")) + }}, + {"case folded duplicate label", "V5", func(r map[string]any) { + object(r, "color_tuning")["baseline_label"] = "TESTED" + s := testState("copy", "setting", "3000") + s["label"] = "tested" + object(r, "color_tuning")["states"] = append(testRows(r), s) + }}, + {"unicode case fold", "V5", func(r map[string]any) { + object(r, "color_tuning")["baseline_label"] = "Σ" + s := testState("copy", "setting", "3000") + s["label"] = "ς" + object(r, "color_tuning")["states"] = append(testRows(r), s) + }}, + {"one unnamed same CCT and dim", "V6", func(r map[string]any) { + object(r, "color_tuning")["baseline_label"] = "Tested" + object(r, "color_tuning")["states"] = append(testRows(r), testState("preset", "setting", "3000")) + }}, + {"different labeled same CCT settings", "", func(r map[string]any) { + object(r, "color_tuning")["baseline_label"] = "Tested" + object(object(r, "configuration"), "tested_axes")["color_tunability"] = "multichannel" + for _, label := range []string{"Circadian", "Standard"} { + s := testState(strings.ToLower(label), "setting", "3000") + s["label"] = label + object(r, "color_tuning")["states"] = append(testRows(r), s) + } + }}, + {"multichannel preset outside white range", "", func(r map[string]any) { + object(object(r, "configuration"), "tested_axes")["color_tunability"] = "multichannel" + object(r, "color_tuning")["states"] = append(testRows(r), testState("preset", "setting", "7000")) + }}, + {"different dim levels at same CCT", "", func(r map[string]any) { + for i, pct := range []float64{10, 50} { + s := testState([]string{"dim-ten", "dim-fifty"}[i], "dim_level", "3000") + s["dim_level_percent"] = pct + object(r, "color_tuning")["states"] = append(testRows(r), s) + } + }}, + {"end repeated as dim level", "V5", func(r map[string]any) { + s := testState("warm-copy", "dim_level", "2700") + s["dim_level_percent"] = 100 + object(r, "color_tuning")["states"] = append(testRows(r), s) + }}, + {"dim to warm baseline end", "", func(r map[string]any) { + object(object(r, "configuration"), "tested_axes")["color_tunability"] = "dim_to_warm" + b := object(r, "color_tuning") + b["baseline_role"] = "range_high_end" + b["baseline_dim_level_percent"] = 100 + b["states"] = testRows(r)[:1] + testRows(r)[0].(map[string]any)["dim_level_percent"] = 10 + }}, + {"settings only", "", func(r map[string]any) { + object(r, "color_tuning")["states"] = []any{testState("preset", "setting", "4000")} + }}, + {"RGBW channels only unnamed baseline", "", func(r map[string]any) { + object(object(r, "configuration"), "tested_axes")["color_tunability"] = "rgbw" + b := object(r, "color_tuning") + delete(b, "states") + b["channels"] = []any{map[string]any{"channel_id": "white", "kind": "white"}} + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := fixture() + tt.change(r) + problems := Problems(r) + if tt.code == "" { + if len(problems) > 0 { + t.Fatalf("unexpected problems: %+v", problems) + } + return + } + for _, p := range problems { + if strings.HasPrefix(p.Message, tt.code+":") { + if p.Path == "" { + t.Fatal("problem has no pointer") + } + return + } + } + t.Fatalf("missing %s: %+v", tt.code, problems) + }) + } +} + +func TestMalformedRecordsAreTotal(t *testing.T) { + for _, r := range []map[string]any{nil, {}, {"color_tuning": map[string]any{"states": []any{nil, 42, map[string]any{}}, "channels": []any{nil, map[string]any{}}}}, {"configuration": false, "colorimetry": []any{}, "color_tuning": true}} { + States(r) + Ends(r) + Problems(r) + } +} diff --git a/tools/validator/internal/findings/findings.go b/tools/validator/internal/findings/findings.go index af979de..92e93b8 100644 --- a/tools/validator/internal/findings/findings.go +++ b/tools/validator/internal/findings/findings.go @@ -38,6 +38,9 @@ const ( // Schema validation. CodeSchemaViolation Code = "schema/violation" + // Published color-state consistency. + CodeColorTuningInconsistent Code = "color-tuning/inconsistent" + // Index / builder parity. CodeIndexBuilderMissingRequired Code = "index/builder-missing-required" CodeIndexDrift Code = "index/drift" From 3bf65b54bf58a0aaf99839bf96c66e61f6f40a6d Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:55:05 -0400 Subject: [PATCH 03/10] Project published CCT ranges in builder 0.11.0 --- examples/cooper-atlite-auxswhsd.ulc | 2 +- examples/cooper-sure-lites-es61src.ulc | 2 +- examples/cooper-sure-lites-lpx7sd.ulc | 2 +- examples/erco-quintessence-30416-023.ulc | 2 +- ...pulse-lumenfacade-loi-12-rgb-30x60-ts0.ulc | 2 +- ...lumenfacade-loi-12-rgbw30k-10x60-ts2-5.ulc | 2 +- examples/selux-aya-pole-sr-ho-3000k.ulc | 2 +- ...ended-807-so-3500k-90cri-hl-black-48in.ulc | 2 +- schema/ulc.schema.json | 8 ++++ tools/validator/internal/index/builder.go | 10 +++- .../internal/index/color_tuning_test.go | 47 +++++++++++++++++++ 11 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 tools/validator/internal/index/color_tuning_test.go diff --git a/examples/cooper-atlite-auxswhsd.ulc b/examples/cooper-atlite-auxswhsd.ulc index 0139318..c371c75 100644 --- a/examples/cooper-atlite-auxswhsd.ulc +++ b/examples/cooper-atlite-auxswhsd.ulc @@ -133,7 +133,7 @@ "damp_location_ul", "ul_924" ], - "builder_version": "0.10.0", + "builder_version": "0.11.0", "catalog_model": "AUXS", "catalog_number": "AUXSWHSD", "conformance_level": "standard", diff --git a/examples/cooper-sure-lites-es61src.ulc b/examples/cooper-sure-lites-es61src.ulc index f87ff4b..da25724 100644 --- a/examples/cooper-sure-lites-es61src.ulc +++ b/examples/cooper-sure-lites-es61src.ulc @@ -121,7 +121,7 @@ "attestation_programs": [ "ul_924" ], - "builder_version": "0.10.0", + "builder_version": "0.11.0", "catalog_model": "ES6", "catalog_number": "ES61SRC", "conformance_level": "standard", diff --git a/examples/cooper-sure-lites-lpx7sd.ulc b/examples/cooper-sure-lites-lpx7sd.ulc index 25e98f0..4fc4a0f 100644 --- a/examples/cooper-sure-lites-lpx7sd.ulc +++ b/examples/cooper-sure-lites-lpx7sd.ulc @@ -111,7 +111,7 @@ "damp_location_ul", "ul_924" ], - "builder_version": "0.10.0", + "builder_version": "0.11.0", "catalog_model": "LPX7", "catalog_number": "LPX7SD", "conformance_level": "core", diff --git a/examples/erco-quintessence-30416-023.ulc b/examples/erco-quintessence-30416-023.ulc index 137c77f..ea664fc 100644 --- a/examples/erco-quintessence-30416-023.ulc +++ b/examples/erco-quintessence-30416-023.ulc @@ -391,7 +391,7 @@ "ul_8750" ], "beam_family": "wide_flood", - "builder_version": "0.10.0", + "builder_version": "0.11.0", "catalog_model": "Quintessence Downlight 30416", "catalog_number": "30416.023", "color_tunability": "static_white", diff --git a/examples/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.ulc b/examples/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.ulc index 767dfd4..2cca343 100644 --- a/examples/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.ulc +++ b/examples/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.ulc @@ -314,7 +314,7 @@ "rohs" ], "beam_family": "asymmetric", - "builder_version": "0.10.0", + "builder_version": "0.11.0", "catalog_model": "Lumenfacade Inground LOI Color Changing", "catalog_number": "LOI-120-12-RGB-30x60-TS0-XX-XX-ASL", "color_tunability": "rgb", diff --git a/examples/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.ulc b/examples/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.ulc index 9bc4d1f..3d274aa 100644 --- a/examples/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.ulc +++ b/examples/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.ulc @@ -332,7 +332,7 @@ "rohs" ], "beam_family": "asymmetric", - "builder_version": "0.10.0", + "builder_version": "0.11.0", "catalog_model": "Lumenfacade Inground LOI Color Changing", "catalog_number": "LOI-120-12-RGBW30K-10X60-TS2.5-INTL-XX-XX", "color_tunability": "rgbw", diff --git a/examples/selux-aya-pole-sr-ho-3000k.ulc b/examples/selux-aya-pole-sr-ho-3000k.ulc index cd6b7be..5b56535 100644 --- a/examples/selux-aya-pole-sr-ho-3000k.ulc +++ b/examples/selux-aya-pole-sr-ho-3000k.ulc @@ -488,7 +488,7 @@ "rohs" ], "bug_rating": "B2-U0-G1", - "builder_version": "0.10.0", + "builder_version": "0.11.0", "catalog_model": "Aya Pole or Pendant (AYAP)", "catalog_number": "selux-aya-pole-sr-ho-3000k", "color_tunability": "static_white", diff --git a/examples/vode-nexa-suspended-807-so-3500k-90cri-hl-black-48in.ulc b/examples/vode-nexa-suspended-807-so-3500k-90cri-hl-black-48in.ulc index bf00372..307a8f2 100644 --- a/examples/vode-nexa-suspended-807-so-3500k-90cri-hl-black-48in.ulc +++ b/examples/vode-nexa-suspended-807-so-3500k-90cri-hl-black-48in.ulc @@ -439,7 +439,7 @@ "ul_1598" ], "beam_family": "medium_flood", - "builder_version": "0.10.0", + "builder_version": "0.11.0", "catalog_model": "Nexa Suspended 807", "catalog_number": "vode-nexa-suspended-807-so-3500k-90cri-hl-black-48in", "color_tunability": "static_white", diff --git a/schema/ulc.schema.json b/schema/ulc.schema.json index 78345b3..bf30738 100644 --- a/schema/ulc.schema.json +++ b/schema/ulc.schema.json @@ -266,6 +266,14 @@ }, "environment_rating": { "$ref": "taxonomy.schema.json#/$defs/EnvironmentRating" }, "nominal_cct_k": { "$ref": "taxonomy.schema.json#/$defs/NominalCCT" }, + "cct_range_low_k": { + "description": "Generated low-end CCT of the published white range. Present with cct_range_high_k only when both ends resolve, including the referenced baseline.", + "type": "number" + }, + "cct_range_high_k": { + "description": "Generated high-end CCT of the published white range. Resolve each state from its nominal bin first, otherwise its measured_cct_k value.", + "type": "number" + }, "nominal_total_lumens": { "type": "number", "minimum": 0 }, "nominal_input_power_w": { "type": "number", "minimum": 0 }, "nominal_cri_ra": { "type": "number", "minimum": 0, "maximum": 100 }, diff --git a/tools/validator/internal/index/builder.go b/tools/validator/internal/index/builder.go index ce3aee1..8495ab3 100644 --- a/tools/validator/internal/index/builder.go +++ b/tools/validator/internal/index/builder.go @@ -17,6 +17,7 @@ import ( "strings" "github.com/ulcspec/ULC/tools/validator/internal/achievements" + "github.com/ulcspec/ULC/tools/validator/internal/colortuning" "github.com/ulcspec/ULC/tools/validator/internal/completeness" ) @@ -63,7 +64,10 @@ import ( // positive number of hours or a populated table. The full method-backed row // also requires a positive projection. Every stored record re-stamps the builder // version; a record's level changes only where one of those four rows decided it. -const BuilderVersion = "0.10.0" +// 0.11.0: project the published white range through the shared state resolver. +// Tunable-white and dim-to-warm grading also reads the range and endpoint metrics. +// Existing examples keep their grades and re-stamp only this marker. +const BuilderVersion = "0.11.0" // RequiredKeys mirrors schema/ulc.schema.json#/$defs/Index.required. The Go // validator enforces this set directly; the legacy Python builder-parity-guard @@ -201,6 +205,10 @@ func Build(record Record) Index { if v := getString(cfg, "tested_axes", "color_tunability"); v != "" { idx["color_tunability"] = v } + if low, high, ok := colortuning.Ends(record); ok { + idx["cct_range_low_k"] = numberToJSON(low.CCT) + idx["cct_range_high_k"] = numberToJSON(high.CCT) + } if v := getString(elec, "driver_protocol"); v != "" { idx["dimming_protocols"] = []any{v} } diff --git a/tools/validator/internal/index/color_tuning_test.go b/tools/validator/internal/index/color_tuning_test.go new file mode 100644 index 0000000..526fe43 --- /dev/null +++ b/tools/validator/internal/index/color_tuning_test.go @@ -0,0 +1,47 @@ +package index + +import "testing" + +func TestColorRangeProjection(t *testing.T) { + state := func(role, cct string) map[string]any { + return map[string]any{"role": role, "colorimetry": map[string]any{"nominal_cct_k": cct}} + } + tests := []struct { + name string + role, baseline string + states []any + low, high float64 + present bool + }{ + {"listed ends", "setting", "3000", []any{state("range_low_end", "2700"), state("range_high_end", "6500")}, 2700, 6500, true}, + {"baseline low end", "range_low_end", "2700", []any{state("range_high_end", "6500")}, 2700, 6500, true}, + {"baseline high end", "range_high_end", "3000", []any{state("range_low_end", "1800")}, 1800, 3000, true}, + {"lone end", "setting", "3000", []any{state("range_low_end", "2700")}, 0, 0, false}, + {"settings only", "setting", "3000", []any{state("setting", "4000")}, 0, 0, false}, + {"off-bin rated end", "range_high_end", "3000", []any{map[string]any{"role": "range_low_end", "colorimetry": map[string]any{"measured_cct_k": map[string]any{"value": 1850, "value_type": "rated"}}}}, 1850, 3000, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := map[string]any{"configuration": map[string]any{"tested_conditions": map[string]any{"nominal_cct_at_test": tt.baseline}}, "colorimetry": map[string]any{"nominal_cct_k": "4000"}, "color_tuning": map[string]any{"baseline_role": tt.role, "states": tt.states}} + idx := Build(r) + lo, loOK := idx["cct_range_low_k"] + hi, hiOK := idx["cct_range_high_k"] + if loOK != tt.present || hiOK != tt.present { + t.Fatalf("range presence = %v/%v, want %v", loOK, hiOK, tt.present) + } + if tt.present { + a, okA := getNumber(map[string]any{"v": lo}, "v") + b, okB := getNumber(map[string]any{"v": hi}, "v") + if !okA || !okB || a != tt.low || b != tt.high { + t.Fatalf("range = %v/%v, want %v/%v", lo, hi, tt.low, tt.high) + } + } + if idx["nominal_cct_k"] != tt.baseline { + t.Fatal("tested baseline projection moved") + } + }) + } + if idx := Build(nil); idx["cct_range_low_k"] != nil || idx["cct_range_high_k"] != nil { + t.Fatal("absent tuning must omit range") + } +} From b750a394261418ddcc4dbfa3ab86317c65d712cd Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Fri, 16 Oct 2026 22:57:01 -0400 Subject: [PATCH 04/10] Grade published white endpoints and describe color-state methodology --- docs/methodology.md | 13 ++- .../completeness/color_tuning_test.go | 83 +++++++++++++++++++ .../internal/completeness/completeness.go | 55 ++++++++++++ .../completeness/completeness_drift_test.go | 2 + .../internal/completeness/scope_test.go | 10 +-- .../completeness/testdata/gating-table.txt | 2 + ...ulse-lumenfacade-loi-12-rgb-30x60-ts0.json | 10 ++- ...pulse-lumenfacade-loi-12-rgb-30x60-ts0.txt | 2 +- ...umenfacade-loi-12-rgbw30k-10x60-ts2-5.json | 10 ++- ...lumenfacade-loi-12-rgbw30k-10x60-ts2-5.txt | 2 +- 10 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 tools/validator/internal/completeness/color_tuning_test.go diff --git a/docs/methodology.md b/docs/methodology.md index 6163fe1..888f4de 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -185,6 +185,7 @@ Conditional rows (each names the predicate that switches it on): | Per-length normalized | `photometry.per_length_normalized` | linear fixture | | Declared-by-length | `photometry.declared_by_length` | linear fixture | | CRI tier | `configuration.tested_axes.cri_tier` | primarily white light | +| Published white range | `color_tuning` (both ends resolve a CCT, counting the baseline) | tunable_white or dim_to_warm | | SDCM step (MacAdam) | `colorimetry.sdcm_step` | primarily white light | | Dimming method | `electrical.dimming_method` | analog or phase-cut driver | | Dimming range | `electrical.dimming_range_percent` | analog or phase-cut driver | @@ -207,6 +208,7 @@ The dimming method and dimming range gates apply only to analog and phase-cut dr | Method-backed lumen maintenance | positive TM-21 projection hours or a positive TM-28 projection | `test_report` | | TM-30 fidelity Rf (conditional: primarily white light) | `colorimetry.tm_30.rf` | `test_report` | | TM-30 per-hue-bin detail (conditional: primarily white light) | `colorimetry.tm_30.rf_h_per_bin` | `test_report` | +| Endpoint CRI Ra and melanopic DER (conditional: tunable_white or dim_to_warm) | `color_tuning/states`, with the baseline endpoint read from the top-level blocks | `datasheet_pdf` | | Measured spectrum as data (conditional: primarily white light) | colorimetry.spectral_power_distribution with source_kind laboratory_table or exchange_file, measured_through_optics true, value_type measured or rated | test_report | `zonal_lumens` sits at standard with the rest of the photometric file: it is computed from the candela grid of that file and needs no test report. The LM-79 attestation sits at full, with the report content it stands for. @@ -215,7 +217,7 @@ The dimming method and dimming range gates apply only to analog and phase-cut dr Beyond the achieved level, the grader emits a roadmap in two parts. The first is the **tier roadmap**: for each grade the record has not yet reached, the specific hard fields it must add to get there, emitted as `conformance/gap` findings that name the source document and governing standard. The second is the **enrichment roadmap**: optional dimensions a record could disclose to deepen the datasheet even though they never change the grade, emitted as `conformance/enrichment` findings. Both are surfaced once a record reaches core. -The enrichment roadmap covers the depth a fuller datasheet carries but the tiers deliberately do not gate: power factor, warranty term, luminous opening shape, emission face, Duv, chromaticity x and y, ambient operating range, compatible accessories, thermal derating, flicker, alpha-opic and circadian metrics, chromaticity-shift projection, field angle, cutoff angle, spacing criterion, UGR, LCS zonal lumens, IK rating, EPA, installed orientation, optical radiation band, adaptive lighting modes, photometric file format, the TM-30 design-intent code, the measured spectrum as data (named again below Full, so a record at core or above sees it without a gate), and the finer sub-fields of the photometry, test-condition, operating-point, lumen-maintenance, colorimetry, and electrical blocks. Each sub-field suggestion fires only when its parent block is genuinely populated and the field itself absent, so an absent block draws a single block-level suggestion rather than sub-field spam. +The enrichment roadmap also names emitter channels on color-mixing records and spectra at both range ends when a color-tuning block is present. It covers the depth a fuller datasheet carries but the tiers deliberately do not gate: power factor, warranty term, luminous opening shape, emission face, Duv, chromaticity x and y, ambient operating range, compatible accessories, thermal derating, flicker, alpha-opic and circadian metrics, chromaticity-shift projection, field angle, cutoff angle, spacing criterion, UGR, LCS zonal lumens, IK rating, EPA, installed orientation, optical radiation band, adaptive lighting modes, photometric file format, the TM-30 design-intent code, the measured spectrum as data (named again below Full, so a record at core or above sees it without a gate), and the finer sub-fields of the photometry, test-condition, operating-point, lumen-maintenance, colorimetry, and electrical blocks. Each sub-field suggestion fires only when its parent block is genuinely populated and the field itself absent, so an absent block draws a single block-level suggestion rather than sub-field spam. A small residual set stays as plain `conformance/observation` notes rather than joining the enrichment roadmap. There are four sources. Two fire when the field is absent, in the same "not disclosed" idiom as the enrichment rows, but are deliberately kept off the roadmap: a sustainability declaration (a tracked program attestation such as an EPD or Declare label, not the datasheet depth a specifier asks a manufacturer to add) and the deprecated legacy-cutoff classification (superseded by the BUG rating and LCS zonal lumens, retained only for lossless ingestion of legacy datasheets and a drop candidate for a future major version). The other two are a note when a headline photometric value (flux, power, or maximum intensity) carries a `value_type` other than `measured` (a rated figure or an optical simulation), and the attestation-coverage summary listing the compliance programs tracked on the record. @@ -231,6 +233,7 @@ This layer replaced an earlier handful of inline conditionals, and three correct | --- | --- | --- | --- | | `hasWhitePoint` | `configuration.tested_axes.color_tunability` | in {static_white, tunable_white, dim_to_warm, rgbw, rgbww} | nominal CCT (core); Duv and chromaticity x/y (enrichment) | | `isWhiteLightPrimary` | `color_tunability` | in {static_white, tunable_white, dim_to_warm} only | CRI Ra (core); CRI tier and SDCM step (standard); TM-30 Rf and hue bins and the measured spectrum as data (full); the TM-30 PVF code and the spectrum nudge (enrichment) | +| `tunesWhite` | `color_tunability` | in {tunable_white, dim_to_warm} | both published endpoints resolve a CCT (standard); both endpoints carry CRI Ra and melanopic DER (full) | | `directional` | `product_family.primary_category` | downlight, tracklight, cylinder, wall_washer, grazer, facade_projector, in_ground_uplight, sports_flood | beam angle (standard); field angle (enrichment) | | `outdoorSite` | `primary_category` | flood_area_site, roadway_street, walkway, bulkhead_wall_pack, sports_flood | joined with `northAmericanMarket` for outdoor distribution type, longitudinal range, and BUG (standard); LCS zonal (enrichment); legacy cutoff (observation) | | `northAmericanMarket` | `product_family.markets` | absent, empty, or contains `north_america` | joined with `outdoorSite` for the three North American outdoor-classification rows | @@ -261,6 +264,14 @@ The first is the outdoor-site boundary. An `in_ground_uplight` and a `facade_pro The second is the white-point versus white-light-primary split. A CCT is meaningful for any fixture with a white point, including the white channel of an RGBW or RGBWW fixture, so `hasWhitePoint` gates nominal CCT broadly. CRI, SDCM, and TM-30 are white-light-quality metrics that a color-mixing fixture does not characterize: an RGBW fixture is specified by its mixing gamut, not a single CRI figure measured against a reference illuminant. The measured spectrum is waived for color-mixing fixtures for the same reason: no single spectrum characterizes them. So `isWhiteLightPrimary` excludes every color-mixing mode (RGBW included), and those quality metrics are waived for color-mixing fixtures rather than counted against them. +### Color states + +The existing colorimetry, alpha-opic and photometry blocks remain the tested baseline. `color_tuning` names its role, label when needed, and dim level; `states` lists only other published states. Every reader uses the shared resolver, which returns the baseline first with its own measurement blocks, then the listed states. Each spectrum lives within its state's colorimetry. The nominal CCT bin takes precedence over the measured value; the baseline uses `configuration.tested_conditions.nominal_cct_at_test` first, then `colorimetry.nominal_cct_k`, then `measured_cct_k.value`. An off-bin published endpoint such as 1850 K uses `measured_cct_k` with value type `rated`, following the existing policy for rated instrument values in measured fields. + +A tunable-white or dim-to-warm record needs both endpoints for Standard and CRI Ra plus melanopic DER at both endpoints for Full. When the tested baseline is an endpoint, its top-level values satisfy that endpoint. The existing grading rows continue to read only the baseline. Presets, dim-level points, channels and endpoint spectra never add a gate. Dim-to-warm uses the high end at full output and the low end at the dimmest published level, with output percentages when published. A stored tunable record without endpoints falls below Standard after rebuilding its index. Static-white records carry no color-tuning block. + +The validator refuses duplicate state or channel identifiers, a lone endpoint, a reversed range, a state outside a tunable-white or dim-to-warm range, and repeated state identities. One identity key applies to every capability: CCT, dim level (absent means 100), and case-folded label (absent means empty). States sharing CCT and dim level must each have a label. Labeled white presets at one CCT are distinct states; an RGBW record may carry channels only and an unnamed tested baseline. A multichannel preset may fall outside its published white range. The index projects both numeric range ends only when both resolve; a range filter reads those ends together with the capability, and a dark-sky filter reads the larger of the range high end and the numeric baseline CCT. + ### Product-class profiles: exit signs and emergency luminaires Two product classes grade against their own dataset rather than the architectural-photometry profile above, so a maker of exit-sign-only or emergency-only products is graded on the evidence its cutsheets actually carry instead of being stranded at incomplete for lacking an LM-79 report it never produces. The class is derived from `product_family.primary_category`, an existing core field: `exit_sign` selects the sign profile, `emergency_luminaire` selects the dedicated-emergency profile, and every other category keeps the normal profile. Keying on a core field means every profile-selecting predicate reads a core field, so the core-fields-only invariant holds by construction and a record cannot select two conflicting profiles. diff --git a/tools/validator/internal/completeness/color_tuning_test.go b/tools/validator/internal/completeness/color_tuning_test.go new file mode 100644 index 0000000..d283c4f --- /dev/null +++ b/tools/validator/internal/completeness/color_tuning_test.go @@ -0,0 +1,83 @@ +package completeness + +import "testing" + +func colorState(role, cct string) map[string]any { + return map[string]any{"role": role, "colorimetry": map[string]any{"nominal_cct_k": cct, "cri_ra": map[string]any{"value": 90.0}}, "alpha_opic_metrics": map[string]any{"melanopic_der": map[string]any{"value": 0.5}}} +} +func addColorRange(r map[string]any) { + r["color_tuning"] = map[string]any{"baseline_role": "setting", "states": []any{colorState("range_low_end", "2700"), colorState("range_high_end", "6500")}} +} + +func TestColorStatesGradeEndpointsAndKeepBaselineRules(t *testing.T) { + for _, capability := range []string{"tunable_white", "dim_to_warm"} { + t.Run(capability, func(t *testing.T) { + r := fullBase() + r["configuration"].(map[string]any)["tested_axes"].(map[string]any)["color_tunability"] = capability + if got := AchievedLevel(r); got != LevelCore { + t.Fatalf("missing ends grade = %v, want core", got) + } + addColorRange(r) + if got := AchievedLevel(r); got != LevelFull { + t.Fatalf("complete endpoints grade = %v, want full", got) + } + low := r["color_tuning"].(map[string]any)["states"].([]any)[0].(map[string]any) + delete(low, "alpha_opic_metrics") + if got := AchievedLevel(r); got != LevelStandard { + t.Fatalf("missing endpoint DER grade = %v, want standard", got) + } + low["alpha_opic_metrics"] = map[string]any{"melanopic_der": map[string]any{"value": 0.3}} + delete(r["colorimetry"].(map[string]any), "cri_ra") + if got := AchievedLevel(r); got != LevelIncomplete { + t.Fatalf("listed-end CRI must not replace baseline CRI: %v", got) + } + }) + } +} + +func TestColorStatesGradeReferencedBaselineEnd(t *testing.T) { + r := fullBase() + r["configuration"].(map[string]any)["tested_axes"].(map[string]any)["color_tunability"] = "tunable_white" + r["color_tuning"] = map[string]any{"baseline_role": "range_high_end", "states": []any{colorState("range_low_end", "2700")}} + if !hasColorRange(r) || hasColorEndMetrics(r) { + t.Fatal("baseline endpoint must require its own DER") + } + r["alpha_opic_metrics"] = map[string]any{"melanopic_der": map[string]any{"value": 0.6}} + if !hasColorEndMetrics(r) || AchievedLevel(r) != LevelFull { + t.Fatal("baseline endpoint metrics were not counted") + } + delete(r["colorimetry"].(map[string]any), "cri_ra") + if hasColorEndMetrics(r) { + t.Fatal("baseline CRI must be required at the endpoint") + } +} + +func TestColorStatesEnrichmentNeverGates(t *testing.T) { + for _, capability := range []string{"rgb", "rgbw", "rgba", "rgbww", "multichannel"} { + r := standardBase() + r["configuration"].(map[string]any)["tested_axes"].(map[string]any)["color_tunability"] = capability + before := AchievedLevel(r) + addColorRange(r) + r["color_tuning"].(map[string]any)["channels"] = []any{map[string]any{"channel_id": "white", "kind": "white"}} + if got := AchievedLevel(r); got != before { + t.Fatalf("%s channels or states moved grade %v to %v", capability, before, got) + } + } + r := fullBase() + r["configuration"].(map[string]any)["tested_axes"].(map[string]any)["color_tunability"] = "tunable_white" + addColorRange(r) + before := AchievedLevel(r) + for _, raw := range r["color_tuning"].(map[string]any)["states"].([]any) { + raw.(map[string]any)["colorimetry"].(map[string]any)["spectral_power_distribution"] = map[string]any{"values": []any{0.1, 0.2}} + } + if !hasColorEndSpectra(r) || AchievedLevel(r) != before { + t.Fatal("end spectra should satisfy enrichment without moving the grade") + } + for _, raw := range []any{nil, true, []any{}, map[string]any{"states": []any{nil, 42}}} { + r := map[string]any{"color_tuning": raw} + hasColorRange(r) + hasColorEndMetrics(r) + hasColorEndSpectra(r) + AchievedLevel(r) + } +} diff --git a/tools/validator/internal/completeness/completeness.go b/tools/validator/internal/completeness/completeness.go index 4557d3b..671d6fd 100644 --- a/tools/validator/internal/completeness/completeness.go +++ b/tools/validator/internal/completeness/completeness.go @@ -64,6 +64,7 @@ import ( "sort" "strings" + "github.com/ulcspec/ULC/tools/validator/internal/colortuning" "github.com/ulcspec/ULC/tools/validator/internal/findings" ) @@ -444,6 +445,55 @@ func isWhiteLightPrimary(r map[string]any) bool { return false } +func tunesWhite(r map[string]any) bool { + capability := getString(r, "configuration", "tested_axes", "color_tunability") + return capability == "tunable_white" || capability == "dim_to_warm" +} + +func mixesColor(r map[string]any) bool { + switch getString(r, "configuration", "tested_axes", "color_tunability") { + case "rgb", "rgbw", "rgba", "rgbww", "multichannel": + return true + } + return false +} + +func hasColorRange(r map[string]any) bool { + _, _, ok := colortuning.Ends(r) + return ok +} + +func hasColorEndMetrics(r map[string]any) bool { + low, high, ok := colortuning.Ends(r) + if !ok { + return false + } + for _, end := range []colortuning.State{low, high} { + if !num("cri_ra")(end.Colorimetry) || !num("melanopic_der")(end.AlphaOpicMetrics) { + return false + } + } + return true +} + +func hasColorEndSpectra(r map[string]any) bool { + low, high, ok := colortuning.Ends(r) + if !ok { + return false + } + for _, end := range []colortuning.State{low, high} { + if spectrum, ok := getMap(end.Colorimetry, "spectral_power_distribution"); !ok || len(spectrum) == 0 { + return false + } + } + return true +} + +func hasColorTuning(r map[string]any) bool { + tuning, ok := getMap(r, "color_tuning") + return ok && len(tuning) > 0 +} + func directional(r map[string]any) bool { return directionalCategories[category(r)] } func outdoorSite(r map[string]any) bool { return outdoorSiteCategories[category(r)] } func linear(r map[string]any) bool { return linearCategories[category(r)] } @@ -895,6 +945,8 @@ var rubric = []rule{ {LevelStandard, "/emergency/battery_chemistry", "BatteryChemistry", "datasheet_pdf", "UL 924", "", str("emergency", "battery_chemistry"), both(emergencyPowerCoreClass, hasIntegralBattery)}, {LevelStandard, "/emergency/self_test", "EmergencySelfTestCapability", "datasheet_pdf", "UL 924", "", str("emergency", "self_test"), both(emergencyPowerCoreClass, hasIntegralBattery)}, + {LevelStandard, "/color_tuning", "", "datasheet_pdf", "identity", "", hasColorRange, tunesWhite}, + // --- FULL --- {LevelFull, "LM-79 attestation", "AttestationProgram", "test_report", "LM-79", "", hasDocumentedLM79Attestation, notExitSign}, {LevelFull, "/operating_point", "", "test_report", "LM-79", "", hasOperatingPoint, notExitSign}, @@ -905,6 +957,7 @@ var rubric = []rule{ {LevelFull, "/colorimetry/tm_30/rf", "", "test_report", "TM-30", "", num("colorimetry", "tm_30", "rf"), isWhiteLightPrimary}, {LevelFull, "/colorimetry/tm_30/rf_h_per_bin", "", "test_report", "TM-30", "", arr("colorimetry", "tm_30", "rf_h_per_bin"), isWhiteLightPrimary}, {LevelFull, "measured spectral power distribution as data (through the optics; table or exchange file)", "", "test_report", "LM-79 / TM-27", "", hasMeasuredSpectrum, isWhiteLightPrimary}, + {LevelFull, "/color_tuning/states", "", "datasheet_pdf", "CIE 13.3", "", hasColorEndMetrics, tunesWhite}, // v0.10.0 exit-sign FULL tier (§2.6): two provenance-reading rows that PARTITION the // sign class by mode, so every sign has exactly one applicable full row and a standard @@ -917,6 +970,8 @@ var rubric = []rule{ {LevelFull, "test-report-backed face illuminance", "", "test_report", "UL 924", "", testReportBacked("exit_sign", "face_illuminance_lx"), both(isExitSign, signMode("externally_illuminated"))}, // --- ENRICHMENT (non-gating; the enrichment roadmap; surfaced at core and above) --- + {LevelEnrichment, "/color_tuning/channels", "", "datasheet_pdf", "identity", "emission channels not disclosed", arr("color_tuning", "channels"), mixesColor}, + {LevelEnrichment, "/color_tuning/states", "", "datasheet_pdf", "LM-79 / TM-27", "endpoint spectra not disclosed", hasColorEndSpectra, hasColorTuning}, // Optional dimensions a record could disclose to deepen the datasheet. Messages // are byte-identical to their prior conformance/observation wording; only the // finding code changes (see the 0.9.0 CHANGELOG consumer-migration note). diff --git a/tools/validator/internal/completeness/completeness_drift_test.go b/tools/validator/internal/completeness/completeness_drift_test.go index f043ae5..49ad1f6 100644 --- a/tools/validator/internal/completeness/completeness_drift_test.go +++ b/tools/validator/internal/completeness/completeness_drift_test.go @@ -214,6 +214,8 @@ func setDataPath(rec map[string]any, comps []string, val any) { // constructor), so a generic schema-correct value would not satisfy it. They are // covered by the behavioral tests instead. var predicateBackedPaths = map[string]bool{ + "/color_tuning": true, + "/color_tuning/states": true, "/colorimetry/spectral_power_distribution": true, // hasMeasuredSpectrum: requires sampled data and evidence qualifiers "/product_family/cutsheet": true, // hasCutsheet: needs an attached cutsheet with a content hash "/emergency/photometry_reference": true, // hasEmergencyPhotometryReference: FileReference needing a content hash (like cutsheet) diff --git a/tools/validator/internal/completeness/scope_test.go b/tools/validator/internal/completeness/scope_test.go index 2c54edf..77e9b87 100644 --- a/tools/validator/internal/completeness/scope_test.go +++ b/tools/validator/internal/completeness/scope_test.go @@ -188,7 +188,7 @@ func TestScopePerClass(t *testing.T) { // --- T2: the frozen public table --- -// TestScopeGatingTable freezes all 69 gating rows as (tier, kind, path, +// TestScopeGatingTable freezes all 71 gating rows as (tier, kind, path, // source_document, standard). The manifest publishes those three strings for // every in-scope row unconditionally, where today they reach the public only for // rows some record happens to miss, so any reword, any added or removed gating @@ -284,8 +284,8 @@ func TestScopeKindPartition(t *testing.T) { if gating+enrichment+observation != len(rubric) { t.Errorf("level split = %d gating + %d enrichment + %d observation, want %d total", gating, enrichment, observation, len(rubric)) } - if gating != 69 || enrichment != 71 || observation != 2 { - t.Errorf("level split = %d/%d/%d, want 69 gating / 71 enrichment / 2 observation", gating, enrichment, observation) + if gating != 71 || enrichment != 73 || observation != 2 { + t.Errorf("level split = %d/%d/%d, want 71 gating / 73 enrichment / 2 observation", gating, enrichment, observation) } // Every tier token the manifest can emit must be one of the three documented @@ -317,8 +317,8 @@ func TestScopeKindPartition(t *testing.T) { seenKey[k] = ru.path } - if counts[ScopeKindPointer] != 59 || counts[ScopeKindChoice] != 2 || counts[ScopeKindRequirement] != 8 { - t.Errorf("gating kind partition = %d pointer / %d choice / %d requirement, want 59/2/8", + if counts[ScopeKindPointer] != 61 || counts[ScopeKindChoice] != 2 || counts[ScopeKindRequirement] != 8 { + t.Errorf("gating kind partition = %d pointer / %d choice / %d requirement, want 61/2/8", counts[ScopeKindPointer], counts[ScopeKindChoice], counts[ScopeKindRequirement]) } // The tables must not carry entries no gating row claims. diff --git a/tools/validator/internal/completeness/testdata/gating-table.txt b/tools/validator/internal/completeness/testdata/gating-table.txt index b496fc9..626771e 100644 --- a/tools/validator/internal/completeness/testdata/gating-table.txt +++ b/tools/validator/internal/completeness/testdata/gating-table.txt @@ -23,6 +23,7 @@ core pointer /product_family/shape datasheet_pdf identity core pointer /product_family/technical_region datasheet_pdf identity core requirement UL 924 listing compliance_documents UL 924 core requirement safety listing (UL/cUL/ETL/CSA for NA; CE/ENEC/IEC 60598 otherwise) compliance_documents UL 1598 / IEC 60598 +full pointer /color_tuning/states datasheet_pdf CIE 13.3 full pointer /colorimetry/tm_30/rf test_report TM-30 full pointer /colorimetry/tm_30/rf_h_per_bin test_report TM-30 full pointer /corrections_applied test_report LM-79 @@ -35,6 +36,7 @@ full requirement method-backed lumen maintenance (TM-21 hours or TM-28) test_rep full requirement test-report-backed face illuminance test_report UL 924 full requirement test-report-backed sign-face luminance test_report UL 924 standard choice /lumen_maintenance_luminaire (or /lumen_maintenance_package) datasheet_pdf TM-21 +standard pointer /color_tuning datasheet_pdf identity standard pointer /colorimetry/sdcm_step datasheet_pdf ANSI C78.377 standard pointer /configuration/tested_axes/cri_tier datasheet_pdf CIE 13.3 standard pointer /electrical/control_gear_type datasheet_pdf LM-79 diff --git a/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.json b/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.json index e5e4fe7..ff7e278 100644 --- a/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.json +++ b/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.json @@ -34,6 +34,14 @@ "source_document": "test_report", "standard": "TM-35" }, + { + "level": "INFO", + "code": "conformance/enrichment", + "message": "emission channels not disclosed", + "path": "/color_tuning/channels", + "source_document": "datasheet_pdf", + "standard": "identity" + }, { "level": "INFO", "code": "conformance/enrichment", @@ -291,6 +299,6 @@ "summary": { "errors": 0, "warnings": 0, - "infos": 38 + "infos": 39 } } diff --git a/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.txt b/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.txt index 02c0cf8..0263be0 100644 --- a/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.txt +++ b/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.txt @@ -6,4 +6,4 @@ INFO conformance/grade-satisfied at /index/conformance_level: conformance gra INFO conformance/grade-satisfied at /index/conformance_level: conformance grade "standard" satisfied INFO conformance/level at /index/conformance_level: this record achieves conformance level "standard" -OK -- lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.ulc: 0 errors, 0 warnings, 38 infos (31 optional findings hidden (enrichment, observations, and achievements); use --verbose or --json). +OK -- lumenpulse-lumenfacade-loi-12-rgb-30x60-ts0.ulc: 0 errors, 0 warnings, 39 infos (32 optional findings hidden (enrichment, observations, and achievements); use --verbose or --json). diff --git a/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.json b/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.json index 341bda4..4e6bb24 100644 --- a/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.json +++ b/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.json @@ -34,6 +34,14 @@ "source_document": "test_report", "standard": "TM-35" }, + { + "level": "INFO", + "code": "conformance/enrichment", + "message": "emission channels not disclosed", + "path": "/color_tuning/channels", + "source_document": "datasheet_pdf", + "standard": "identity" + }, { "level": "INFO", "code": "conformance/enrichment", @@ -315,6 +323,6 @@ "summary": { "errors": 0, "warnings": 0, - "infos": 41 + "infos": 42 } } diff --git a/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.txt b/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.txt index 874b676..65f9d41 100644 --- a/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.txt +++ b/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.txt @@ -6,4 +6,4 @@ INFO conformance/grade-satisfied at /index/conformance_level: conformance gra INFO conformance/grade-satisfied at /index/conformance_level: conformance grade "standard" satisfied INFO conformance/level at /index/conformance_level: this record achieves conformance level "standard" -OK -- lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.ulc: 0 errors, 0 warnings, 41 infos (34 optional findings hidden (enrichment, observations, and achievements); use --verbose or --json). +OK -- lumenpulse-lumenfacade-loi-12-rgbw30k-10x60-ts2-5.ulc: 0 errors, 0 warnings, 42 infos (35 optional findings hidden (enrichment, observations, and achievements); use --verbose or --json). From 8814c56de649b7c7e309062cb7144879e3511917 Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:04:03 -0400 Subject: [PATCH 05/10] Convert published color states, emitter channels, and their spectra --- templates/workbook/README.md | 14 + templates/workbook/color_states.csv | 1 + templates/workbook/emission_channels.csv | 1 + templates/workbook/records.csv | 2 +- .../workbook/spectral_power_distribution.csv | 2 +- tools/validator/internal/sheet/DESIGN.md | 4 +- .../validator/internal/sheet/color_tuning.go | 252 ++++++++++++++++++ .../internal/sheet/color_tuning_test.go | 196 ++++++++++++++ .../sheet/colorimetry_columns_test.go | 14 +- tools/validator/internal/sheet/columns.go | 5 + .../internal/sheet/companion_headers_test.go | 4 +- tools/validator/internal/sheet/convert.go | 8 + tools/validator/internal/sheet/fulllevel.go | 39 +-- .../internal/sheet/spec_version_test.go | 3 + .../internal/sheet/supplementary_columns.go | 11 + .../sheet/supplementary_provenance_test.go | 11 + 16 files changed, 538 insertions(+), 29 deletions(-) create mode 100644 templates/workbook/color_states.csv create mode 100644 templates/workbook/emission_channels.csv create mode 100644 tools/validator/internal/sheet/color_tuning.go create mode 100644 tools/validator/internal/sheet/color_tuning_test.go diff --git a/templates/workbook/README.md b/templates/workbook/README.md index 38425a0..b6eb48e 100644 --- a/templates/workbook/README.md +++ b/templates/workbook/README.md @@ -178,6 +178,8 @@ release 1.5.0 on. | `excluded_combinations` | SKUs orderable elsewhere but out of scope for this record. | Patterns B and D | | `alpha_opic` | Alpha-opic / melanopic per-photoreceptor efficacy with per-value provenance companions. Every filled `efficacy` requires an authored `efficacy_unit` of `W/lm` or `mW/lm`. | Full enrichment | | `spectral_power_distribution` | One row per wavelength sample for a uniformly spaced spectrum, with provenance for the whole spectrum. | Colorimetry; a Full requirement for white-light records from 1.15.0 | +| `color_states` | Published luminaire states other than the tested baseline, with CCT, dim level, quality metrics, melanopic DER, flux and provenance companions. | Tunable or color-mixing records | +| `emission_channels` | Emitter channels with kind, nominal CCT or peak wavelength, flux and provenance companions. | Color-mixing records | | `tm_30_hue_bins` | The sixteen TM-30 local fidelity values (Rf,h), one row per hue bin 1 to 16, with per-value provenance companions. Needs `tm_30_rf` on `records`. | White-light records; a Full requirement | | `flicker_metrics` | TLA metrics (SVM, Pst_LM, percent flicker) with per-value provenance companions and a metric-specific unit rule. | Full enrichment | | `lumen_maintenance_package` | LM-80 / TM-21 method-backed projection with companions on its three numeric values. | Full enrichment | @@ -237,6 +239,18 @@ From release 1.15.0 the Full conformance level of a white-light record requires Use `tm_30_hue_bins` for the sixteen local color fidelity values (Rf,h) of a TM-30 report, one row per bin: `record_id`, an integer `bin` from 1 to 16, and a numeric `rf_h`. A record that uses the sheet carries all sixteen bins, each once, in any row order, and its records row carries `tm_30_rf`; a missing, repeated or out-of-range bin, a blank or non-numeric value, and bins without that headline are refused. The six `rf_h__*` companions and `conflict_notes` apply per row. The default is measured, from a test report, transcribed, and linked to the record's single LM-79 attestation, as for `tm_30_rf`; a `tm_30*` attestation cannot anchor it. The values land at `colorimetry.tm_30.rf_h_per_bin`, ordered by bin, which the Full level of a white-light record requires. Use an `ulc` converter at release 1.17.0 or newer for this sheet; an older one ignores the whole sheet without a message. +## Color-state sheet + +For a tunable-white or dim-to-warm record, keep the tested baseline on `records` and fill `color_tuning_baseline_role` with `range_low_end`, `range_high_end`, `setting` or `dim_level`. Fill `color_tuning_baseline_label` when printed and `color_tuning_baseline_dim_level_percent` for a dim-level baseline; an endpoint may also carry its published percent. A setting cannot carry a percent. The role requires at least one row on `color_states` or `emission_channels`; static-white records and records without `color_tunability` cannot carry these rows. + +Use one `color_states` row for each other published state: `state_id` (a unique lowercase slug), `role`, the printed `label` when needed, `dim_level_percent`, and `nominal_cct_k` as a taxonomy bin or `measured_cct_k` as a numeric value. An off-bin rated endpoint uses `measured_cct_k__value_type=rated`. The other value columns are `duv`, `cri_ra`, `cri_r9`, `tm_30_rf`, `tm_30_rg`, `melanopic_der`, `lumens` and `efficacy_lm_per_w`. Each has the six provenance companions; defaults are rated, datasheet_pdf, transcribed. A dim-level state requires a percent greater than zero and at most 100. Do not repeat the baseline or its endpoint role. States sharing CCT and dim level must each have a printed label; ask the manufacturer for a missing name rather than inventing one. Both white endpoints are required for Standard; their CRI Ra and melanopic DER are required for Full. Other settings and dim-level points never gate a grade. + +## Emission-channel sheet and state spectra + +Use one `emission_channels` row per emitter on a color-mixing record: unique lowercase `channel_id`, `kind` (`white` or `color`), the printed `label`, a `nominal_cct_k` bin when applicable, `peak_wavelength_nm` and `lumens` when published. The two numeric values carry the same six companions and rated, datasheet_pdf, transcribed defaults. A channel describes an emitter, so a white channel may repeat the baseline values without becoming a second luminaire state. + +On `spectral_power_distribution`, leave both `state_id` and `channel_id` blank for the baseline. Set exactly one identifier for a listed state or channel. Each group is an independent uniformly spaced spectrum with its own metadata and provenance, kept under that state's or channel's `colorimetry.spectral_power_distribution`. Unknown identifiers and rows carrying both identifiers are refused. Use release 1.18.0 or newer; an older converter ignores the new sheets. + ## Attestation evidence columns On `attestations` and `shared_attestations`, use `valid_until` (an ISO date in diff --git a/templates/workbook/color_states.csv b/templates/workbook/color_states.csv new file mode 100644 index 0000000..c5eeec9 --- /dev/null +++ b/templates/workbook/color_states.csv @@ -0,0 +1 @@ +record_id,state_id,role,label,dim_level_percent,nominal_cct_k,measured_cct_k,duv,cri_ra,cri_r9,tm_30_rf,tm_30_rg,melanopic_der,lumens,efficacy_lm_per_w,conflict_notes,measured_cct_k__value_type,measured_cct_k__prov_source,measured_cct_k__prov_method,measured_cct_k__extension_method,measured_cct_k__base_attestation_ref,measured_cct_k__attestation_ref,duv__value_type,duv__prov_source,duv__prov_method,duv__extension_method,duv__base_attestation_ref,duv__attestation_ref,cri_ra__value_type,cri_ra__prov_source,cri_ra__prov_method,cri_ra__extension_method,cri_ra__base_attestation_ref,cri_ra__attestation_ref,cri_r9__value_type,cri_r9__prov_source,cri_r9__prov_method,cri_r9__extension_method,cri_r9__base_attestation_ref,cri_r9__attestation_ref,tm_30_rf__value_type,tm_30_rf__prov_source,tm_30_rf__prov_method,tm_30_rf__extension_method,tm_30_rf__base_attestation_ref,tm_30_rf__attestation_ref,tm_30_rg__value_type,tm_30_rg__prov_source,tm_30_rg__prov_method,tm_30_rg__extension_method,tm_30_rg__base_attestation_ref,tm_30_rg__attestation_ref,melanopic_der__value_type,melanopic_der__prov_source,melanopic_der__prov_method,melanopic_der__extension_method,melanopic_der__base_attestation_ref,melanopic_der__attestation_ref,lumens__value_type,lumens__prov_source,lumens__prov_method,lumens__extension_method,lumens__base_attestation_ref,lumens__attestation_ref,efficacy_lm_per_w__value_type,efficacy_lm_per_w__prov_source,efficacy_lm_per_w__prov_method,efficacy_lm_per_w__extension_method,efficacy_lm_per_w__base_attestation_ref,efficacy_lm_per_w__attestation_ref diff --git a/templates/workbook/emission_channels.csv b/templates/workbook/emission_channels.csv new file mode 100644 index 0000000..11d652f --- /dev/null +++ b/templates/workbook/emission_channels.csv @@ -0,0 +1 @@ +record_id,channel_id,kind,label,nominal_cct_k,peak_wavelength_nm,lumens,conflict_notes,peak_wavelength_nm__value_type,peak_wavelength_nm__prov_source,peak_wavelength_nm__prov_method,peak_wavelength_nm__extension_method,peak_wavelength_nm__base_attestation_ref,peak_wavelength_nm__attestation_ref,lumens__value_type,lumens__prov_source,lumens__prov_method,lumens__extension_method,lumens__base_attestation_ref,lumens__attestation_ref diff --git a/templates/workbook/records.csv b/templates/workbook/records.csv index 6afd9f8..b2bdf1a 100644 --- a/templates/workbook/records.csv +++ b/templates/workbook/records.csv @@ -1 +1 @@ -record_id,cutsheet_file,cutsheet_file__revision_label,cutsheet_file__revision_date,ulc_version,record_status,record_status_as_of,superseded_by_record_id,superseded_by_record_sha256,superseded_by_catalog_number,superseded_by_catalog_model,discontinued_at,family_id,family_display_name,family_description,manufacturer_slug,manufacturer_display_name,catalog_line,catalog_model,primary_category,secondary_function,indoor_outdoor,technical_region,markets,mounting_types,environment_rating,shape,housing_material,lens_material,reflector_material,finish_color_options,ip_rating,ik_rating,warranty_term_years,warranty_term_basis,warranty_scope,warranty_conditions_file,warranty_conditions_file__revision_label,warranty_conditions_file__revision_date,overall_diameter_mm,overall_diameter_in,ceiling_aperture_mm,ceiling_aperture_in,recess_depth_mm,recess_depth_in,connection_cable_length_mm,connection_cable_length_in,overall_length_mm,overall_length_in,overall_width_mm,overall_width_in,overall_height_mm,overall_height_in,luminaire_mass_kg,luminaire_mass_lb,linear_mass_per_foot_kg_per_m,linear_mass_per_foot_lb_per_ft,photometric_scenario_id,catalog_number,scenario_label,source_ies_ref,distribution_manufacturer_label,distribution_type,outdoor_distribution_type_axis,light_engine_variant,output_tier_manufacturer_label,output_tier_meaning,cri_tier,color_tunability,nominal_cct_at_test,input_voltage_at_test,mounting_at_test,input_power_w,input_voltage_v,input_voltage_class,power_factor,power_factor_bound_operator,thd_percent,thd_percent_bound_operator,driver_protocol,dimming_method,dimming_range_min_percent,dimming_range_max_percent,dimming_curve,control_gear_type,led_module_power_w,total_luminous_flux_lm,luminaire_efficacy_lm_per_w,maximum_intensity_cd,beam_angle_deg,field_angle_deg,ugr_4h_8h,ugr_4h_8h_bound_operator,max_surface_luminance_cd_per_m2,max_surface_luminance_bound_operator,beam_family,distribution_type_photometry,symmetry_type,photometric_coordinate_system,luminous_opening_shape,emission_face,lumens_per_foot,watts_per_foot,reference_length_mm,reference_length_in,operating_input_voltage_v,operating_input_frequency_hz,nominal_cct_k,cri_ra,duv,sdcm_step,measured_cct_k,measured_cct_k__value_type,measured_cct_k__prov_source,measured_cct_k__prov_method,measured_cct_k__extension_method,measured_cct_k__base_attestation_ref,measured_cct_k__attestation_ref,chromaticity_x,chromaticity_x__value_type,chromaticity_x__prov_source,chromaticity_x__prov_method,chromaticity_x__extension_method,chromaticity_x__base_attestation_ref,chromaticity_x__attestation_ref,chromaticity_y,chromaticity_y__value_type,chromaticity_y__prov_source,chromaticity_y__prov_method,chromaticity_y__extension_method,chromaticity_y__base_attestation_ref,chromaticity_y__attestation_ref,cri_r9,cri_r9__value_type,cri_r9__prov_source,cri_r9__prov_method,cri_r9__extension_method,cri_r9__base_attestation_ref,cri_r9__attestation_ref,tm_30_rf,tm_30_rf__value_type,tm_30_rf__prov_source,tm_30_rf__prov_method,tm_30_rf__extension_method,tm_30_rf__base_attestation_ref,tm_30_rf__attestation_ref,tm_30_rg,tm_30_rg__value_type,tm_30_rg__prov_source,tm_30_rg__prov_method,tm_30_rg__extension_method,tm_30_rg__base_attestation_ref,tm_30_rg__attestation_ref,tm_30_reference_illuminant_type,tm_30_pvf_code,bug_b,bug_u,bug_g,outdoor_distribution_type,longitudinal_distribution_range,photometry_basis,measurement_regime,lm_declaration_framework,lm_claim_type,lm_claimed_hours,lm_claim_basis,sustainability_declaration_type,sustainability_document_id,sustainability_issue_date,sustainability_expiration_date,final_assembly_location,life_expectancy_years,recyclable_percent,end_of_life_options,lbc_criteria_compliance,voc_content,interior_performance,responsible_sourcing,extensions_json,extensions_slug,applicable_catalog_pattern,fixed_axes,applicable_sku_count_estimate +record_id,cutsheet_file,cutsheet_file__revision_label,cutsheet_file__revision_date,ulc_version,record_status,record_status_as_of,superseded_by_record_id,superseded_by_record_sha256,superseded_by_catalog_number,superseded_by_catalog_model,discontinued_at,family_id,family_display_name,family_description,manufacturer_slug,manufacturer_display_name,catalog_line,catalog_model,primary_category,secondary_function,indoor_outdoor,technical_region,markets,mounting_types,environment_rating,shape,housing_material,lens_material,reflector_material,finish_color_options,ip_rating,ik_rating,warranty_term_years,warranty_term_basis,warranty_scope,warranty_conditions_file,warranty_conditions_file__revision_label,warranty_conditions_file__revision_date,overall_diameter_mm,overall_diameter_in,ceiling_aperture_mm,ceiling_aperture_in,recess_depth_mm,recess_depth_in,connection_cable_length_mm,connection_cable_length_in,overall_length_mm,overall_length_in,overall_width_mm,overall_width_in,overall_height_mm,overall_height_in,luminaire_mass_kg,luminaire_mass_lb,linear_mass_per_foot_kg_per_m,linear_mass_per_foot_lb_per_ft,photometric_scenario_id,catalog_number,scenario_label,source_ies_ref,distribution_manufacturer_label,distribution_type,outdoor_distribution_type_axis,light_engine_variant,output_tier_manufacturer_label,output_tier_meaning,cri_tier,color_tunability,nominal_cct_at_test,input_voltage_at_test,mounting_at_test,input_power_w,input_voltage_v,input_voltage_class,power_factor,power_factor_bound_operator,thd_percent,thd_percent_bound_operator,driver_protocol,dimming_method,dimming_range_min_percent,dimming_range_max_percent,dimming_curve,control_gear_type,led_module_power_w,total_luminous_flux_lm,luminaire_efficacy_lm_per_w,maximum_intensity_cd,beam_angle_deg,field_angle_deg,ugr_4h_8h,ugr_4h_8h_bound_operator,max_surface_luminance_cd_per_m2,max_surface_luminance_bound_operator,beam_family,distribution_type_photometry,symmetry_type,photometric_coordinate_system,luminous_opening_shape,emission_face,lumens_per_foot,watts_per_foot,reference_length_mm,reference_length_in,operating_input_voltage_v,operating_input_frequency_hz,nominal_cct_k,cri_ra,duv,sdcm_step,measured_cct_k,measured_cct_k__value_type,measured_cct_k__prov_source,measured_cct_k__prov_method,measured_cct_k__extension_method,measured_cct_k__base_attestation_ref,measured_cct_k__attestation_ref,chromaticity_x,chromaticity_x__value_type,chromaticity_x__prov_source,chromaticity_x__prov_method,chromaticity_x__extension_method,chromaticity_x__base_attestation_ref,chromaticity_x__attestation_ref,chromaticity_y,chromaticity_y__value_type,chromaticity_y__prov_source,chromaticity_y__prov_method,chromaticity_y__extension_method,chromaticity_y__base_attestation_ref,chromaticity_y__attestation_ref,cri_r9,cri_r9__value_type,cri_r9__prov_source,cri_r9__prov_method,cri_r9__extension_method,cri_r9__base_attestation_ref,cri_r9__attestation_ref,tm_30_rf,tm_30_rf__value_type,tm_30_rf__prov_source,tm_30_rf__prov_method,tm_30_rf__extension_method,tm_30_rf__base_attestation_ref,tm_30_rf__attestation_ref,tm_30_rg,tm_30_rg__value_type,tm_30_rg__prov_source,tm_30_rg__prov_method,tm_30_rg__extension_method,tm_30_rg__base_attestation_ref,tm_30_rg__attestation_ref,tm_30_reference_illuminant_type,tm_30_pvf_code,bug_b,bug_u,bug_g,outdoor_distribution_type,longitudinal_distribution_range,photometry_basis,measurement_regime,lm_declaration_framework,lm_claim_type,lm_claimed_hours,lm_claim_basis,sustainability_declaration_type,sustainability_document_id,sustainability_issue_date,sustainability_expiration_date,final_assembly_location,life_expectancy_years,recyclable_percent,end_of_life_options,lbc_criteria_compliance,voc_content,interior_performance,responsible_sourcing,extensions_json,extensions_slug,applicable_catalog_pattern,fixed_axes,applicable_sku_count_estimate,color_tuning_baseline_role,color_tuning_baseline_label,color_tuning_baseline_dim_level_percent diff --git a/templates/workbook/spectral_power_distribution.csv b/templates/workbook/spectral_power_distribution.csv index 4d02563..983edfa 100644 --- a/templates/workbook/spectral_power_distribution.csv +++ b/templates/workbook/spectral_power_distribution.csv @@ -1 +1 @@ -record_id,wavelength_nm,value,unit,source_kind,measured_through_optics,conflict_notes,value__value_type,value__prov_source,value__prov_method,value__extension_method,value__base_attestation_ref,value__attestation_ref +record_id,wavelength_nm,value,unit,source_kind,measured_through_optics,conflict_notes,value__value_type,value__prov_source,value__prov_method,value__extension_method,value__base_attestation_ref,value__attestation_ref,state_id,channel_id diff --git a/tools/validator/internal/sheet/DESIGN.md b/tools/validator/internal/sheet/DESIGN.md index 75dac83..44982a3 100644 --- a/tools/validator/internal/sheet/DESIGN.md +++ b/tools/validator/internal/sheet/DESIGN.md @@ -66,7 +66,9 @@ field has an SI and an Imperial entry column; you author exactly one, see sectio | `lumen_maintenance_package` | LM-80 / TM-21 method-backed rows | `(record_id,pkg)` | full | | `additional_rated_claims` | further lumen-maintenance thresholds beside the records-sheet headline | `(record_id,claim)` | A B C D | | `alpha_opic` | per-photoreceptor efficacy (assembled into `alpha_opic_metrics`) | `(record_id,channel)` | full | -| `spectral_power_distribution` | one uniformly spaced wavelength sample per row, with block-level provenance | `(record_id,wavelength_nm)` | full | +| `spectral_power_distribution` | one uniformly spaced wavelength sample per row, with block-level provenance | `(record_id,state_id,channel_id,wavelength_nm)` | full | +| `color_states` | published luminaire states beside the referenced tested baseline | `(record_id,state_id)` | A B C D | +| `emission_channels` | emitter kind, CCT or peak wavelength and flux | `(record_id,channel_id)` | A B C D | | `tm_30_hue_bins` | the sixteen TM-30 local fidelity values, one bin per row with per-row provenance (assembled into `colorimetry.tm_30.rf_h_per_bin`) | `(record_id,bin)` | full | | `flicker_metrics` | TLA metrics (SVM, Pst_LM) | `(record_id,metric)` | full | | `zonal_lumens` / `lcs_zonal_lumens` | angle-band + TM-15 LCS zones | `(record_id,zone)` | B / outdoor | diff --git a/tools/validator/internal/sheet/color_tuning.go b/tools/validator/internal/sheet/color_tuning.go new file mode 100644 index 0000000..1425c98 --- /dev/null +++ b/tools/validator/internal/sheet/color_tuning.go @@ -0,0 +1,252 @@ +package sheet + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + "sync" + + embedded "github.com/ulcspec/ULC/schema" + "github.com/ulcspec/ULC/tools/validator/internal/colortuning" +) + +var colorIDPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) +var colorEnumsOnce sync.Once +var colorEnums map[string]map[string]bool +var colorEnumsErr error + +// Read converter refusal domains from the canonical taxonomy, without a second +// hand-maintained copy of its roles, channel kinds, or nominal CCT bins. +func colorEnum(name, token string) bool { + colorEnumsOnce.Do(func() { + var doc struct { + Defs map[string]struct { + Enum []string `json:"enum"` + } `json:"$defs"` + } + colorEnumsErr = json.Unmarshal(embedded.TaxonomySchemaJSON, &doc) + colorEnums = map[string]map[string]bool{} + for name, def := range doc.Defs { + members := map[string]bool{} + for _, v := range def.Enum { + members[v] = true + } + colorEnums[name] = members + } + }) + return colorEnumsErr == nil && colorEnums[name][token] +} + +func checkColorDim(role, raw string) error { + if role == "dim_level" && raw == "" { + return fmt.Errorf("dim_level requires dim_level_percent") + } + if role == "setting" && raw != "" { + return fmt.Errorf("setting forbids dim_level_percent") + } + if raw != "" { + v, err := parseFloat(raw) + if err != nil || v <= 0 || v > 100 { + return fmt.Errorf("dim_level_percent must be numeric and in (0, 100]") + } + } + return nil +} + +func checkColorTuningRows(wb Workbook, id string, master Row, recordRow int) error { + stateRows, channelRows := wb.RowsFor("color_states", id), wb.RowsFor("emission_channels", id) + role := master["color_tuning_baseline_role"] + if len(stateRows)+len(channelRows) == 0 && role == "" && master["color_tuning_baseline_label"] == "" && master["color_tuning_baseline_dim_level_percent"] == "" { + return nil + } + recordsError := func(msg string) error { return fmt.Errorf("records row %d for %q: %s", recordRow, id, msg) } + capability := master["color_tunability"] + if capability == "" || capability == "static_white" { + for _, sheet := range []string{"color_states", "emission_channels"} { + if len(wb.RowsFor(sheet, id)) > 0 { + return fmt.Errorf("%s row 1 for %q: rows require a non-static color_tunability", sheet, id) + } + } + return recordsError("color_tuning requires a non-static color_tunability") + } + if role == "" { + return recordsError("color states or channels require color_tuning_baseline_role") + } + if len(stateRows)+len(channelRows) == 0 { + return recordsError("color_tuning_baseline_role requires color_states or emission_channels rows") + } + if !colorEnum("ColorStateRole", role) { + return recordsError("color_tuning_baseline_role is outside ColorStateRole") + } + if err := checkColorDim(role, master["color_tuning_baseline_dim_level_percent"]); err != nil { + return recordsError(err.Error()) + } + for _, spec := range []struct{ sheet, id, enum, field string }{{"color_states", "state_id", "ColorStateRole", "role"}, {"emission_channels", "channel_id", "EmissionChannelKind", "kind"}} { + seen := map[string]bool{} + for i, row := range wb.RowsFor(spec.sheet, id) { + bad := func(msg string) error { return fmt.Errorf("%s row %d for %q: %s", spec.sheet, i+1, id, msg) } + key := row[spec.id] + if !colorIDPattern.MatchString(key) { + return bad(spec.id + " must be a nonempty lowercase slug") + } + if seen[key] { + return bad("repeated " + spec.id) + } + seen[key] = true + if !colorEnum(spec.enum, row[spec.field]) { + return bad(spec.field + " is outside " + spec.enum) + } + if bin := row["nominal_cct_k"]; bin != "" && !colorEnum("NominalCCT", bin) { + return bad("nominal_cct_k is not a NominalCCT bin") + } + if spec.sheet == "color_states" { + if row["nominal_cct_k"] == "" && row["measured_cct_k"] == "" { + return bad("state requires a nominal_cct_k or measured_cct_k") + } + if err := checkColorDim(row["role"], row["dim_level_percent"]); err != nil { + return bad(err.Error()) + } + if (role == "range_low_end" || role == "range_high_end") && row["role"] == role { + return bad("listed end repeats the baseline role") + } + } + } + } + return nil +} + +var colorStateValuePaths = []struct{ field, path string }{ + {"measured_cct_k", "colorimetry.measured_cct_k"}, {"duv", "colorimetry.duv"}, + {"cri_ra", "colorimetry.cri_ra"}, {"cri_r9", "colorimetry.cri_r9"}, + {"tm_30_rf", "colorimetry.tm_30.rf"}, {"tm_30_rg", "colorimetry.tm_30.rg"}, + {"melanopic_der", "alpha_opic_metrics.melanopic_der"}, {"lumens", "lumens"}, {"efficacy_lm_per_w", "efficacy_lm_per_w"}, +} + +func assembleColorTuning(wb Workbook, id string, rec map[string]any, ctx provenanceContext) error { + for _, sheet := range []string{"color_states", "emission_channels"} { + rows := wb.RowsFor(sheet, id) + if len(rows) == 0 { + continue + } + out := make([]any, 0, len(rows)) + for i, row := range rows { + entry := map[string]any{} + fields := []string{"state_id", "role", "label"} + paths := colorStateValuePaths + target := "states" + if sheet == "emission_channels" { + fields = []string{"channel_id", "kind", "label"} + paths = []struct{ field, path string }{{"peak_wavelength_nm", "peak_wavelength_nm"}, {"lumens", "lumens"}} + target = "channels" + } + for _, field := range fields { + copyIf(entry, row, field, field) + } + if bin := row["nominal_cct_k"]; bin != "" { + if err := setPath(entry, "colorimetry.nominal_cct_k", bin); err != nil { + return err + } + } + if raw := row["dim_level_percent"]; raw != "" && sheet == "color_states" { + v, err := parseNumber(raw) + if err != nil { + return fmt.Errorf("%s row %d for %q: %w", sheet, i+1, id, err) + } + entry["dim_level_percent"] = v + } + for _, spec := range paths { + if row[spec.field] == "" { + continue + } + v, err := supplementaryProvenancedNumber(sheet, spec.field, row, ctx) + if err != nil { + return fmt.Errorf("%s row %d for %q: %w", sheet, i+1, id, err) + } + if err := setPath(entry, spec.path, v); err != nil { + return err + } + } + out = append(out, entry) + if err := setPath(rec, "color_tuning."+target, out); err != nil { + return err + } + } + } + return nil +} + +func checkColorTuningProblems(id string, rec map[string]any, recordRow int) error { + problems := colortuning.Problems(rec) + if len(problems) == 0 { + return nil + } + p := problems[0] + sheet, row := "records", recordRow + parts := strings.Split(p.Path, "/") + if len(parts) > 3 { + if n, err := strconv.Atoi(parts[3]); err == nil { + row = n + 1 + switch parts[2] { + case "states": + sheet = "color_states" + case "channels": + sheet = "emission_channels" + } + } + } + return fmt.Errorf("%s row %d for %q: %s at %s", sheet, row, id, p.Message, p.Path) +} + +// Group spectra by state or channel while preserving original row order and row +// numbers. Blank identifiers still target the baseline exactly as before. +func assembleSpectralPowerDistribution(wb Workbook, id string, rec map[string]any, ctx provenanceContext) error { + rows := wb.RowsFor("spectral_power_distribution", id) + type key struct{ state, channel string } + type group struct { + rows []Row + numbers []int + } + groups := map[key]*group{} + var order []key + for i, row := range rows { + k := key{row["state_id"], row["channel_id"]} + if k.state != "" && k.channel != "" { + return fmt.Errorf("spectral_power_distribution row %d for %q: use state_id or channel_id, never both", i+1, id) + } + if groups[k] == nil { + groups[k] = &group{} + order = append(order, k) + } + groups[k].rows = append(groups[k].rows, row) + groups[k].numbers = append(groups[k].numbers, i+1) + } + for _, k := range order { + g := groups[k] + target := rec + if k.state != "" || k.channel != "" { + array, idKey, want := "states", "state_id", k.state + if k.channel != "" { + array, idKey, want = "channels", "channel_id", k.channel + } + block, _ := rec["color_tuning"].(map[string]any) + entries, _ := block[array].([]any) + target = nil + for _, raw := range entries { + entry, _ := raw.(map[string]any) + if entry[idKey] == want { + target = entry + break + } + } + if target == nil { + return fmt.Errorf("spectral_power_distribution row %d for %q: unknown %s %q", g.numbers[0], id, idKey, want) + } + } + if err := assembleSpectrumRows(g.rows, g.numbers, id, target, ctx); err != nil { + return err + } + } + return nil +} diff --git a/tools/validator/internal/sheet/color_tuning_test.go b/tools/validator/internal/sheet/color_tuning_test.go new file mode 100644 index 0000000..2d9befd --- /dev/null +++ b/tools/validator/internal/sheet/color_tuning_test.go @@ -0,0 +1,196 @@ +package sheet + +import ( + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/ulcspec/ULC/tools/validator/internal/colortuning" + "github.com/ulcspec/ULC/tools/validator/internal/findings" + "github.com/ulcspec/ULC/tools/validator/internal/index" + "github.com/ulcspec/ULC/tools/validator/internal/validate" +) + +func writeColorRows(t *testing.T, bundle, sheet string, rows ...Row) { + t.Helper() + header := readCSVRows(t, filepath.Join(filepath.Dir(schemaDir(t)), "templates", "workbook", sheet+".csv"))[0] + out := [][]string{header} + id := bundleRecordID(t, bundle) + for _, row := range rows { + cells := make([]string, len(header)) + for i, h := range header { + cells[i] = row[h] + if h == "record_id" { + cells[i] = id + } + } + out = append(out, cells) + } + writeCSVRows(t, filepath.Join(bundle, sheet+".csv"), out) +} +func colorBundle(t *testing.T) string { + t.Helper() + bundle := colourBundle(t, map[string]string{"color_tunability": "tunable_white", "nominal_cct_at_test": "3000", "nominal_cct_k": "3000", "color_tuning_baseline_role": "setting"}) + writeColorRows(t, bundle, "color_states", Row{"state_id": "warm", "role": "range_low_end", "nominal_cct_k": "2700", "cri_ra": "90", "melanopic_der": "0.3", "lumens": "1200"}, Row{"state_id": "cool", "role": "range_high_end", "nominal_cct_k": "6500", "cri_ra": "90", "lumens": "1350"}) + return bundle +} +func editColorCell(t *testing.T, bundle, sheet string, row int, field, value string) { + t.Helper() + path := filepath.Join(bundle, sheet+".csv") + rows := readCSVRows(t, path) + for i, h := range rows[0] { + if h == field { + rows[row][i] = value + writeCSVRows(t, path, rows) + return + } + } + t.Fatalf("missing header %s on %s", field, sheet) +} + +func TestColorStatesConvertAndGroupSpectraAcrossReaders(t *testing.T) { + bundle := colorBundle(t) + setClaimsCell(t, bundle, "records", "color_tunability", "multichannel") + writeColorRows(t, bundle, "emission_channels", Row{"channel_id": "red", "kind": "color", "peak_wavelength_nm": "630", "lumens": "100"}, Row{"channel_id": "white", "kind": "white", "nominal_cct_k": "3000"}) + for field, value := range map[string]string{"measured_cct_k": "2711", "duv": "0.001", "cri_r9": "70", "tm_30_rf": "90", "tm_30_rg": "99", "efficacy_lm_per_w": "80", "cri_ra__value_type": "nominal", "cri_ra__prov_source": "manufacturer_direct", "cri_ra__prov_method": "transcribed"} { + editColorCell(t, bundle, "color_states", 1, field, value) + } + writeColorRows(t, bundle, "spectral_power_distribution", + Row{"wavelength_nm": "380", "value": "1", "unit": "relative", "source_kind": "laboratory_table", "value__value_type": "rated", "value__prov_source": "datasheet_pdf"}, + Row{"state_id": "warm", "wavelength_nm": "380", "value": "2", "unit": "relative", "source_kind": "laboratory_table", "value__value_type": "rated", "value__prov_source": "datasheet_pdf"}, + Row{"channel_id": "red", "wavelength_nm": "380", "value": "3", "unit": "relative", "source_kind": "laboratory_table", "value__value_type": "rated", "value__prov_source": "datasheet_pdf"}, + Row{"wavelength_nm": "385", "value": "4"}, Row{"state_id": "warm", "wavelength_nm": "385", "value": "5"}, Row{"channel_id": "red", "wavelength_nm": "385", "value": "6"}) + var first map[string]any + for reader, input := range supplementaryInputs(t, bundle) { + t.Run(reader, func(t *testing.T) { + r := convertOneRecord(t, input, Options{}).Record + low, high, ok := colortuning.Ends(r) + if !ok || low.CCT != 2700 || high.CCT != 6500 { + t.Fatal("nominal CCT must win over measured CCT") + } + assertProvenanceDefaults(t, "state measured CCT", low.Colorimetry["measured_cct_k"], "K", "rated", "datasheet_pdf", "transcribed", "") + assertProvenanceDefaults(t, "state DER", low.AlphaOpicMetrics["melanopic_der"], "ratio", "rated", "datasheet_pdf", "transcribed", "") + cri := low.Colorimetry["cri_ra"].(map[string]any) + if _, present := cri["unit"]; present { + t.Fatal("dimensionless CRI must omit unit") + } + if cri["value_type"] != "nominal" || !reflect.DeepEqual(cri["provenance"], map[string]any{"source": "manufacturer_direct", "method": "transcribed"}) { + t.Fatalf("CRI override = %v", cri) + } + baseline, _ := getPath(r, "colorimetry.spectral_power_distribution.values") + if baseline.([]any)[0] != int64(1) { + t.Fatal("baseline spectrum changed target") + } + if low.Colorimetry["spectral_power_distribution"].(map[string]any)["values"].([]any)[0] != int64(2) { + t.Fatal("state spectrum changed target") + } + channels := arrayAt(t, r, "color_tuning.channels") + red := channels[0].(map[string]any) + if red["colorimetry"].(map[string]any)["spectral_power_distribution"].(map[string]any)["values"].([]any)[0] != int64(3) { + t.Fatal("channel spectrum changed target") + } + assertProvenanceDefaults(t, "channel peak", red["peak_wavelength_nm"], "nm", "rated", "datasheet_pdf", "transcribed", "") + r["index"] = index.Build(r) + if first == nil { + first = r + } else if !reflect.DeepEqual(first, r) { + t.Fatal("CSV and XLSX records differ") + } + v, err := validate.NewValidator(schemaDir(t)) + if err != nil { + t.Fatal(err) + } + report := findings.NewReport() + v.Validate(r, report) + if report.HasErrors() { + t.Fatalf("converted record fails schema: %+v", report.Findings) + } + }) + } +} + +func TestColorStatesRefusalsC1ToC9AcrossReaders(t *testing.T) { + tests := []struct { + name, want string + edit func(string) + }{ + {"C1 static white", "non-static", func(b string) { setClaimsCell(t, b, "records", "color_tunability", "static_white") }}, + {"C1 no capability", "non-static", func(b string) { setClaimsCell(t, b, "records", "color_tunability", "") }}, + {"C2 rows without role", "require color_tuning_baseline_role", func(b string) { setClaimsCell(t, b, "records", "color_tuning_baseline_role", "") }}, + {"C2 role without rows", "requires color_states", func(b string) { writeColorRows(t, b, "color_states") }}, + {"C3 blank state id", "nonempty lowercase slug", func(b string) { editColorCell(t, b, "color_states", 1, "state_id", "") }}, + {"C3 nonslug state id", "nonempty lowercase slug", func(b string) { editColorCell(t, b, "color_states", 1, "state_id", "Warm State") }}, + {"C3 duplicate state id", "repeated state_id", func(b string) { editColorCell(t, b, "color_states", 2, "state_id", "warm") }}, + {"C3 duplicate channel id", "repeated channel_id", func(b string) { + writeColorRows(t, b, "emission_channels", Row{"channel_id": "red", "kind": "color"}, Row{"channel_id": "red", "kind": "color"}) + }}, + {"C4 state role", "outside ColorStateRole", func(b string) { editColorCell(t, b, "color_states", 1, "role", "unknown") }}, + {"C4 baseline role", "outside ColorStateRole", func(b string) { setClaimsCell(t, b, "records", "color_tuning_baseline_role", "unknown") }}, + {"C4 channel kind", "outside EmissionChannelKind", func(b string) { writeColorRows(t, b, "emission_channels", Row{"channel_id": "red", "kind": "unknown"}) }}, + {"C5 missing CCT", "state requires", func(b string) { editColorCell(t, b, "color_states", 1, "nominal_cct_k", "") }}, + {"C5 nonbin CCT", "not a NominalCCT bin", func(b string) { editColorCell(t, b, "color_states", 1, "nominal_cct_k", "1850") }}, + {"C6 dim state without percent", "requires dim_level_percent", func(b string) { editColorCell(t, b, "color_states", 1, "role", "dim_level") }}, + {"C6 setting with percent", "setting forbids", func(b string) { setClaimsCell(t, b, "records", "color_tuning_baseline_dim_level_percent", "50") }}, + {"C6 zero percent", "in (0, 100]", func(b string) { editColorCell(t, b, "color_states", 1, "dim_level_percent", "0") }}, + {"C6 high percent", "in (0, 100]", func(b string) { editColorCell(t, b, "color_states", 1, "dim_level_percent", "101") }}, + {"C7 both spectrum ids", "never both", func(b string) { + writeColorRows(t, b, "spectral_power_distribution", Row{"state_id": "warm", "channel_id": "red"}) + }}, + {"C7 unknown state id", "unknown state_id", func(b string) { writeColorRows(t, b, "spectral_power_distribution", Row{"state_id": "missing"}) }}, + {"C7 unknown channel id", "unknown channel_id", func(b string) { writeColorRows(t, b, "spectral_power_distribution", Row{"channel_id": "missing"}) }}, + {"C8 repeated baseline end", "repeats the baseline role", func(b string) { setClaimsCell(t, b, "records", "color_tuning_baseline_role", "range_low_end") }}, + {"C9 lone end", "V2:", func(b string) { editColorCell(t, b, "color_states", 2, "role", "setting") }}, + {"C9 reversed range", "V3:", func(b string) { editColorCell(t, b, "color_states", 1, "nominal_cct_k", "6500") }}, + {"C9 out of range", "V4:", func(b string) { setClaimsCell(t, b, "records", "nominal_cct_at_test", "1800") }}, + {"C9 repeated identity", "V5:", func(b string) { editColorCell(t, b, "color_states", 1, "nominal_cct_k", "3000") }}, + {"C9 unnamed shared CCT", "V6:", func(b string) { + setClaimsCell(t, b, "records", "color_tuning_baseline_label", "Tested") + editColorCell(t, b, "color_states", 1, "nominal_cct_k", "3000") + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := colorBundle(t) + tt.edit(b) + for reader, input := range supplementaryInputs(t, b) { + t.Run(reader, func(t *testing.T) { + _, err := Convert(input, Options{}) + if err == nil || !strings.Contains(err.Error(), tt.want) || !strings.Contains(err.Error(), "row ") || !strings.Contains(err.Error(), bundleRecordID(t, b)) { + t.Fatalf("error = %v, want contextual %q refusal", err, tt.want) + } + }) + } + }) + } +} + +func TestColorStatesDimToWarmAndChannelsOnly(t *testing.T) { + b := colorBundle(t) + setClaimsCell(t, b, "records", "color_tunability", "dim_to_warm") + setClaimsCell(t, b, "records", "color_tuning_baseline_role", "range_high_end") + setClaimsCell(t, b, "records", "color_tuning_baseline_dim_level_percent", "100") + writeColorRows(t, b, "color_states", Row{"state_id": "warm", "role": "range_low_end", "measured_cct_k": "1850", "dim_level_percent": "10"}) + for reader, input := range supplementaryInputs(t, b) { + t.Run(reader, func(t *testing.T) { + r := convertOneRecord(t, input, Options{}).Record + low, high, ok := colortuning.Ends(r) + if !ok || low.CCT != 1850 || high.CCT != 3000 || low.DimLevelPercent != 10 || !high.Baseline { + t.Fatalf("dim-to-warm ends = %+v %+v %v", low, high, ok) + } + }) + } + setClaimsCell(t, b, "records", "color_tunability", "rgbw") + setClaimsCell(t, b, "records", "color_tuning_baseline_role", "setting") + setClaimsCell(t, b, "records", "color_tuning_baseline_dim_level_percent", "") + writeColorRows(t, b, "color_states") + writeColorRows(t, b, "emission_channels", Row{"channel_id": "white", "kind": "white", "nominal_cct_k": "3000"}) + for reader, input := range supplementaryInputs(t, b) { + t.Run(reader+" channels only", func(t *testing.T) { + r := convertOneRecord(t, input, Options{}).Record + if len(colortuning.States(r)) != 1 { + t.Fatal("channels must not become luminaire states") + } + }) + } +} diff --git a/tools/validator/internal/sheet/colorimetry_columns_test.go b/tools/validator/internal/sheet/colorimetry_columns_test.go index 5189c38..af86ce6 100644 --- a/tools/validator/internal/sheet/colorimetry_columns_test.go +++ b/tools/validator/internal/sheet/colorimetry_columns_test.go @@ -34,7 +34,7 @@ var colourNumericHeaders = []string{ "measured_cct_k", "chromaticity_x", "chromaticity_y", "cri_r9", "tm_30_rf", "tm_30_rg", } -const expectedRecordsTemplateHeaders = "record_id,cutsheet_file,cutsheet_file__revision_label,cutsheet_file__revision_date,ulc_version,record_status,record_status_as_of,superseded_by_record_id,superseded_by_record_sha256,superseded_by_catalog_number,superseded_by_catalog_model,discontinued_at,family_id,family_display_name,family_description,manufacturer_slug,manufacturer_display_name,catalog_line,catalog_model,primary_category,secondary_function,indoor_outdoor,technical_region,markets,mounting_types,environment_rating,shape,housing_material,lens_material,reflector_material,finish_color_options,ip_rating,ik_rating,warranty_term_years,warranty_term_basis,warranty_scope,warranty_conditions_file,warranty_conditions_file__revision_label,warranty_conditions_file__revision_date,overall_diameter_mm,overall_diameter_in,ceiling_aperture_mm,ceiling_aperture_in,recess_depth_mm,recess_depth_in,connection_cable_length_mm,connection_cable_length_in,overall_length_mm,overall_length_in,overall_width_mm,overall_width_in,overall_height_mm,overall_height_in,luminaire_mass_kg,luminaire_mass_lb,linear_mass_per_foot_kg_per_m,linear_mass_per_foot_lb_per_ft,photometric_scenario_id,catalog_number,scenario_label,source_ies_ref,distribution_manufacturer_label,distribution_type,outdoor_distribution_type_axis,light_engine_variant,output_tier_manufacturer_label,output_tier_meaning,cri_tier,color_tunability,nominal_cct_at_test,input_voltage_at_test,mounting_at_test,input_power_w,input_voltage_v,input_voltage_class,power_factor,power_factor_bound_operator,thd_percent,thd_percent_bound_operator,driver_protocol,dimming_method,dimming_range_min_percent,dimming_range_max_percent,dimming_curve,control_gear_type,led_module_power_w,total_luminous_flux_lm,luminaire_efficacy_lm_per_w,maximum_intensity_cd,beam_angle_deg,field_angle_deg,ugr_4h_8h,ugr_4h_8h_bound_operator,max_surface_luminance_cd_per_m2,max_surface_luminance_bound_operator,beam_family,distribution_type_photometry,symmetry_type,photometric_coordinate_system,luminous_opening_shape,emission_face,lumens_per_foot,watts_per_foot,reference_length_mm,reference_length_in,operating_input_voltage_v,operating_input_frequency_hz,nominal_cct_k,cri_ra,duv,sdcm_step,measured_cct_k,measured_cct_k__value_type,measured_cct_k__prov_source,measured_cct_k__prov_method,measured_cct_k__extension_method,measured_cct_k__base_attestation_ref,measured_cct_k__attestation_ref,chromaticity_x,chromaticity_x__value_type,chromaticity_x__prov_source,chromaticity_x__prov_method,chromaticity_x__extension_method,chromaticity_x__base_attestation_ref,chromaticity_x__attestation_ref,chromaticity_y,chromaticity_y__value_type,chromaticity_y__prov_source,chromaticity_y__prov_method,chromaticity_y__extension_method,chromaticity_y__base_attestation_ref,chromaticity_y__attestation_ref,cri_r9,cri_r9__value_type,cri_r9__prov_source,cri_r9__prov_method,cri_r9__extension_method,cri_r9__base_attestation_ref,cri_r9__attestation_ref,tm_30_rf,tm_30_rf__value_type,tm_30_rf__prov_source,tm_30_rf__prov_method,tm_30_rf__extension_method,tm_30_rf__base_attestation_ref,tm_30_rf__attestation_ref,tm_30_rg,tm_30_rg__value_type,tm_30_rg__prov_source,tm_30_rg__prov_method,tm_30_rg__extension_method,tm_30_rg__base_attestation_ref,tm_30_rg__attestation_ref,tm_30_reference_illuminant_type,tm_30_pvf_code,bug_b,bug_u,bug_g,outdoor_distribution_type,longitudinal_distribution_range,photometry_basis,measurement_regime,lm_declaration_framework,lm_claim_type,lm_claimed_hours,lm_claim_basis,sustainability_declaration_type,sustainability_document_id,sustainability_issue_date,sustainability_expiration_date,final_assembly_location,life_expectancy_years,recyclable_percent,end_of_life_options,lbc_criteria_compliance,voc_content,interior_performance,responsible_sourcing,extensions_json,extensions_slug,applicable_catalog_pattern,fixed_axes,applicable_sku_count_estimate" +const expectedRecordsTemplateHeaders = "record_id,cutsheet_file,cutsheet_file__revision_label,cutsheet_file__revision_date,ulc_version,record_status,record_status_as_of,superseded_by_record_id,superseded_by_record_sha256,superseded_by_catalog_number,superseded_by_catalog_model,discontinued_at,family_id,family_display_name,family_description,manufacturer_slug,manufacturer_display_name,catalog_line,catalog_model,primary_category,secondary_function,indoor_outdoor,technical_region,markets,mounting_types,environment_rating,shape,housing_material,lens_material,reflector_material,finish_color_options,ip_rating,ik_rating,warranty_term_years,warranty_term_basis,warranty_scope,warranty_conditions_file,warranty_conditions_file__revision_label,warranty_conditions_file__revision_date,overall_diameter_mm,overall_diameter_in,ceiling_aperture_mm,ceiling_aperture_in,recess_depth_mm,recess_depth_in,connection_cable_length_mm,connection_cable_length_in,overall_length_mm,overall_length_in,overall_width_mm,overall_width_in,overall_height_mm,overall_height_in,luminaire_mass_kg,luminaire_mass_lb,linear_mass_per_foot_kg_per_m,linear_mass_per_foot_lb_per_ft,photometric_scenario_id,catalog_number,scenario_label,source_ies_ref,distribution_manufacturer_label,distribution_type,outdoor_distribution_type_axis,light_engine_variant,output_tier_manufacturer_label,output_tier_meaning,cri_tier,color_tunability,nominal_cct_at_test,input_voltage_at_test,mounting_at_test,input_power_w,input_voltage_v,input_voltage_class,power_factor,power_factor_bound_operator,thd_percent,thd_percent_bound_operator,driver_protocol,dimming_method,dimming_range_min_percent,dimming_range_max_percent,dimming_curve,control_gear_type,led_module_power_w,total_luminous_flux_lm,luminaire_efficacy_lm_per_w,maximum_intensity_cd,beam_angle_deg,field_angle_deg,ugr_4h_8h,ugr_4h_8h_bound_operator,max_surface_luminance_cd_per_m2,max_surface_luminance_bound_operator,beam_family,distribution_type_photometry,symmetry_type,photometric_coordinate_system,luminous_opening_shape,emission_face,lumens_per_foot,watts_per_foot,reference_length_mm,reference_length_in,operating_input_voltage_v,operating_input_frequency_hz,nominal_cct_k,cri_ra,duv,sdcm_step,measured_cct_k,measured_cct_k__value_type,measured_cct_k__prov_source,measured_cct_k__prov_method,measured_cct_k__extension_method,measured_cct_k__base_attestation_ref,measured_cct_k__attestation_ref,chromaticity_x,chromaticity_x__value_type,chromaticity_x__prov_source,chromaticity_x__prov_method,chromaticity_x__extension_method,chromaticity_x__base_attestation_ref,chromaticity_x__attestation_ref,chromaticity_y,chromaticity_y__value_type,chromaticity_y__prov_source,chromaticity_y__prov_method,chromaticity_y__extension_method,chromaticity_y__base_attestation_ref,chromaticity_y__attestation_ref,cri_r9,cri_r9__value_type,cri_r9__prov_source,cri_r9__prov_method,cri_r9__extension_method,cri_r9__base_attestation_ref,cri_r9__attestation_ref,tm_30_rf,tm_30_rf__value_type,tm_30_rf__prov_source,tm_30_rf__prov_method,tm_30_rf__extension_method,tm_30_rf__base_attestation_ref,tm_30_rf__attestation_ref,tm_30_rg,tm_30_rg__value_type,tm_30_rg__prov_source,tm_30_rg__prov_method,tm_30_rg__extension_method,tm_30_rg__base_attestation_ref,tm_30_rg__attestation_ref,tm_30_reference_illuminant_type,tm_30_pvf_code,bug_b,bug_u,bug_g,outdoor_distribution_type,longitudinal_distribution_range,photometry_basis,measurement_regime,lm_declaration_framework,lm_claim_type,lm_claimed_hours,lm_claim_basis,sustainability_declaration_type,sustainability_document_id,sustainability_issue_date,sustainability_expiration_date,final_assembly_location,life_expectancy_years,recyclable_percent,end_of_life_options,lbc_criteria_compliance,voc_content,interior_performance,responsible_sourcing,extensions_json,extensions_slug,applicable_catalog_pattern,fixed_axes,applicable_sku_count_estimate,color_tuning_baseline_role,color_tuning_baseline_label,color_tuning_baseline_dim_level_percent" func colourBundle(t *testing.T, cells map[string]string) string { t.Helper() @@ -140,11 +140,11 @@ func colourSchemaErrors(t *testing.T, input string) string { } func TestMeasuredColourColumnTableIsExact(t *testing.T) { - if len(baseRecordColumns) != 125 { - t.Fatalf("baseRecordColumns count = %d, want 125", len(baseRecordColumns)) + if len(baseRecordColumns) != 128 { + t.Fatalf("baseRecordColumns count = %d, want 128", len(baseRecordColumns)) } - if len(recordColumns) != 135 { - t.Fatalf("recordColumns count = %d, want 135", len(recordColumns)) + if len(recordColumns) != 138 { + t.Fatalf("recordColumns count = %d, want 138", len(recordColumns)) } start := -1 for i, column := range baseRecordColumns { @@ -193,8 +193,8 @@ func TestMeasuredColourTemplateHeaderContract(t *testing.T) { if !reflect.DeepEqual(header, want) { t.Fatalf("records template header order changed\n got: %s\nwant: %s", strings.Join(header, ","), expectedRecordsTemplateHeaders) } - if len(header) != 183 { - t.Fatalf("records template headers = %d, want 183", len(header)) + if len(header) != 186 { + t.Fatalf("records template headers = %d, want 186", len(header)) } seen := map[string]bool{} for _, name := range header { diff --git a/tools/validator/internal/sheet/columns.go b/tools/validator/internal/sheet/columns.go index 2de77b6..d9a2d4f 100644 --- a/tools/validator/internal/sheet/columns.go +++ b/tools/validator/internal/sheet/columns.go @@ -216,6 +216,11 @@ var baseRecordColumns = []Column{ {Header: "operating_input_voltage_v", Path: "operating_point.input_voltage_v", Kind: KindProvNumber, Unit: "V", ProvSource: "datasheet_pdf", ProvMethod: "extracted", ProvValueType: "rated"}, {Header: "operating_input_frequency_hz", Path: "operating_point.input_frequency_hz", Kind: KindProvNumber, Unit: "Hz", ProvSource: "datasheet_pdf", ProvMethod: "extracted", ProvValueType: "rated"}, + // --- referenced color-tuning baseline --- + {Header: "color_tuning_baseline_role", Path: "color_tuning.baseline_role", Kind: KindEnum}, + {Header: "color_tuning_baseline_label", Path: "color_tuning.baseline_label", Kind: KindString}, + {Header: "color_tuning_baseline_dim_level_percent", Path: "color_tuning.baseline_dim_level_percent", Kind: KindNumber}, + // --- colorimetry --- {Header: "nominal_cct_k", Path: "colorimetry.nominal_cct_k", Kind: KindEnum}, {Header: "cri_ra", Path: "colorimetry.cri_ra", Kind: KindProvNumber, ProvSource: "ies", ProvMethod: "extracted", ProvValueType: "measured"}, diff --git a/tools/validator/internal/sheet/companion_headers_test.go b/tools/validator/internal/sheet/companion_headers_test.go index fd9a545..31f2751 100644 --- a/tools/validator/internal/sheet/companion_headers_test.go +++ b/tools/validator/internal/sheet/companion_headers_test.go @@ -87,8 +87,8 @@ func writeTemplateHeaderBundle(t *testing.T) string { if err != nil { t.Fatalf("read workbook templates: %v", err) } - if len(templates.Headers) != 20 { - t.Fatalf("workbook template carries %d CSV sheets, want 20", len(templates.Headers)) + if len(templates.Headers) != 22 { + t.Fatalf("workbook template carries %d CSV sheets, want 22", len(templates.Headers)) } sourceDir := filepath.Join("testdata", "bundle-b") source, err := ReadCSVBundle(sourceDir) diff --git a/tools/validator/internal/sheet/convert.go b/tools/validator/internal/sheet/convert.go index 7cc7aab..72e6577 100644 --- a/tools/validator/internal/sheet/convert.go +++ b/tools/validator/internal/sheet/convert.go @@ -122,6 +122,10 @@ func Convert(input string, opts Options) ([]Result, error) { } seen[id] = struct{}{} + if err := checkColorTuningRows(wb, id, master, i+1); err != nil { + return nil, err + } + hasher := &fileHasher{assetsRoot: assetsRoot, allowMissing: opts.AllowMissingFiles, digests: digests} pattern := detectPattern(wb, id, master) @@ -129,6 +133,9 @@ func Convert(input string, opts Options) ([]Result, error) { if err != nil { return nil, fmt.Errorf("record %q: %w", id, err) } + if err := checkColorTuningProblems(id, rec, i+1); err != nil { + return nil, err + } results = append(results, Result{ RecordID: id, Pattern: pattern, @@ -144,6 +151,7 @@ func Convert(input string, opts Options) ([]Result, error) { // record by record_id. The preflight validates these (and only these, so extra // workbook tabs like instructions or a changelog are ignored). var consumedRelatedSheets = map[string]bool{ + "color_states": true, "emission_channels": true, "source_files": true, "attestations": true, "shared_attestations": true, "customization_openness": true, "covered_axes": true, "cct_multipliers": true, "declared_by_length": true, diff --git a/tools/validator/internal/sheet/fulllevel.go b/tools/validator/internal/sheet/fulllevel.go index 5d43968..7e44a57 100644 --- a/tools/validator/internal/sheet/fulllevel.go +++ b/tools/validator/internal/sheet/fulllevel.go @@ -43,6 +43,9 @@ func assembleFullLevelBlocks(wb Workbook, id string, rec map[string]any, ctx pro if err := assembleAlphaOpic(wb, id, rec, ctx); err != nil { return err } + if err := assembleColorTuning(wb, id, rec, ctx); err != nil { + return err + } if err := assembleSpectralPowerDistribution(wb, id, rec, ctx); err != nil { return err } @@ -67,7 +70,10 @@ func assembleFullLevelBlocks(wb Workbook, id string, rec map[string]any, ctx pro if err := assembleIngredientList(wb, id, rec); err != nil { return err } - return assembleCIE97Table(wb, id, rec) + if err := assembleCIE97Table(wb, id, rec); err != nil { + return err + } + return nil } func assembleTM30HueBins(wb Workbook, id string, rec map[string]any, ctx provenanceContext) error { @@ -157,17 +163,16 @@ func assembleAdditionalRatedClaims(wb Workbook, id string, rec map[string]any, c return setPath(rec, "lumen_maintenance_luminaire.additional_rated_claims", out) } -// assembleSpectralPowerDistribution reads one row per wavelength sample. Values +// assembleSpectrumRows reads one group of wavelength samples. Values // retain file order; record-wide metadata may appear on any row, but conflicting // non-blank cells are refused rather than silently choosing one. -func assembleSpectralPowerDistribution(wb Workbook, id string, rec map[string]any, ctx provenanceContext) error { +func assembleSpectrumRows(rows []Row, rowNumbers []int, id string, rec map[string]any, ctx provenanceContext) error { const sheet = "spectral_power_distribution" - rows := wb.RowsFor(sheet, id) if len(rows) == 0 { return nil } if len(rows) < 2 { - return fmt.Errorf("%s row 1 for %q: at least two wavelength samples are required", sheet, id) + return fmt.Errorf("%s row %d for %q: at least two wavelength samples are required", sheet, rowNumbers[0], id) } blockFields := []string{"unit", "source_kind", "measured_through_optics", "conflict_notes"} for _, suffix := range CompanionHeaderSuffixes[CompanionFamilyProvenance] { @@ -180,24 +185,24 @@ func assembleSpectralPowerDistribution(wb Workbook, id string, rec map[string]an for _, field := range blockFields { if value := row[field]; value != "" { if previousValue := merged[field]; previousValue != "" && previousValue != value { - return fmt.Errorf("%s row %d for %q: conflicting %s values %q and %q", sheet, i+1, id, field, previousValue, value) + return fmt.Errorf("%s row %d for %q: conflicting %s values %q and %q", sheet, rowNumbers[i], id, field, previousValue, value) } merged[field] = value } } if row["wavelength_nm"] == "" { - return fmt.Errorf("%s row %d for %q: missing wavelength_nm", sheet, i+1, id) + return fmt.Errorf("%s row %d for %q: missing wavelength_nm", sheet, rowNumbers[i], id) } wavelength, err := parseFloat(row["wavelength_nm"]) if err != nil { - return fmt.Errorf("%s row %d for %q: wavelength_nm: %w", sheet, i+1, id, err) + return fmt.Errorf("%s row %d for %q: wavelength_nm: %w", sheet, rowNumbers[i], id, err) } if row["value"] == "" { - return fmt.Errorf("%s row %d for %q: missing value", sheet, i+1, id) + return fmt.Errorf("%s row %d for %q: missing value", sheet, rowNumbers[i], id) } value, err := parseFloat(row["value"]) if err != nil { - return fmt.Errorf("%s row %d for %q: value: %w", sheet, i+1, id, err) + return fmt.Errorf("%s row %d for %q: value: %w", sheet, rowNumbers[i], id, err) } values = append(values, numberLeaf(value)) switch i { @@ -206,26 +211,26 @@ func assembleSpectralPowerDistribution(wb Workbook, id string, rec map[string]an case 1: step = roundTo(wavelength-start, 9) if step <= 0 { - return fmt.Errorf("%s row %d for %q: wavelength_nm must be strictly ascending at a positive step", sheet, i+1, id) + return fmt.Errorf("%s row %d for %q: wavelength_nm must be strictly ascending at a positive step", sheet, rowNumbers[i], id) } default: if wavelength <= previous { - return fmt.Errorf("%s row %d for %q: wavelength_nm must be strictly ascending", sheet, i+1, id) + return fmt.Errorf("%s row %d for %q: wavelength_nm must be strictly ascending", sheet, rowNumbers[i], id) } } if i > 0 && math.Abs(wavelength-(start+float64(i)*step)) > 1e-6 { - return fmt.Errorf("%s row %d for %q: wavelength_nm %g is off the uniform grid", sheet, i+1, id, wavelength) + return fmt.Errorf("%s row %d for %q: wavelength_nm %g is off the uniform grid", sheet, rowNumbers[i], id, wavelength) } previous = wavelength } column := supplementaryValueColumns[supplementaryValueKey{sheet: sheet, field: "value"}] unit, err := declaredSupplementaryUnit(column, merged, "value") if err != nil { - return fmt.Errorf("%s row 1 for %q: %w", sheet, id, err) + return fmt.Errorf("%s row %d for %q: %w", sheet, rowNumbers[0], id, err) } resolved, err := resolveProvenanceForField("value", column.defaults, merged, ctx) if err != nil { - return fmt.Errorf("%s row 1 for %q: %w", sheet, id, err) + return fmt.Errorf("%s row %d for %q: %w", sheet, rowNumbers[0], id, err) } if note := merged["conflict_notes"]; note != "" { resolved.provenance["conflict_notes"] = note @@ -242,10 +247,10 @@ func assembleSpectralPowerDistribution(wb Workbook, id string, rec map[string]an if raw := merged["measured_through_optics"]; raw != "" { measured, err := parseBool(raw) if err != nil { - return fmt.Errorf("%s row 1 for %q: measured_through_optics: %w", sheet, id, err) + return fmt.Errorf("%s row %d for %q: measured_through_optics: %w", sheet, rowNumbers[0], id, err) } if !measured && resolved.valueType == "measured" { - return fmt.Errorf("%s row 1 for %q: measured_through_optics=false conflicts with effective value__value_type=measured; set value__value_type=rated for a package spectrum", sheet, id) + return fmt.Errorf("%s row %d for %q: measured_through_optics=false conflicts with effective value__value_type=measured; set value__value_type=rated for a package spectrum", sheet, rowNumbers[0], id) } spectrum["measured_through_optics"] = measured } diff --git a/tools/validator/internal/sheet/spec_version_test.go b/tools/validator/internal/sheet/spec_version_test.go index ab2204b..6ae6865 100644 --- a/tools/validator/internal/sheet/spec_version_test.go +++ b/tools/validator/internal/sheet/spec_version_test.go @@ -31,6 +31,9 @@ var recordColumnHeaders = []string{ "chromaticity_x", "chromaticity_y", "color_tunability", + "color_tuning_baseline_dim_level_percent", + "color_tuning_baseline_label", + "color_tuning_baseline_role", "connection_cable_length_in", "connection_cable_length_mm", "control_gear_type", diff --git a/tools/validator/internal/sheet/supplementary_columns.go b/tools/validator/internal/sheet/supplementary_columns.go index dfb7d8b..0daf7ab 100644 --- a/tools/validator/internal/sheet/supplementary_columns.go +++ b/tools/validator/internal/sheet/supplementary_columns.go @@ -19,6 +19,17 @@ type supplementaryValueColumn struct { // supplementary sheet in this release. declared_by_length remains on its // existing hardcoded path because section 4 item 5 was cut. var supplementaryValueColumns = map[supplementaryValueKey]supplementaryValueColumn{ + {sheet: "color_states", field: "measured_cct_k"}: {unit: "K", defaults: provenanceDefaults{valueType: "rated", source: "datasheet_pdf", method: "transcribed", family: attestationFamilyPhotometric}}, + {sheet: "color_states", field: "duv"}: {unit: "", defaults: provenanceDefaults{valueType: "rated", source: "datasheet_pdf", method: "transcribed", family: attestationFamilyPhotometric}}, + {sheet: "color_states", field: "cri_ra"}: {unit: "", defaults: provenanceDefaults{valueType: "rated", source: "datasheet_pdf", method: "transcribed", family: attestationFamilyPhotometric}}, + {sheet: "color_states", field: "cri_r9"}: {unit: "", defaults: provenanceDefaults{valueType: "rated", source: "datasheet_pdf", method: "transcribed", family: attestationFamilyPhotometric}}, + {sheet: "color_states", field: "tm_30_rf"}: {unit: "", defaults: provenanceDefaults{valueType: "rated", source: "datasheet_pdf", method: "transcribed", family: attestationFamilyPhotometric}}, + {sheet: "color_states", field: "tm_30_rg"}: {unit: "", defaults: provenanceDefaults{valueType: "rated", source: "datasheet_pdf", method: "transcribed", family: attestationFamilyPhotometric}}, + {sheet: "color_states", field: "melanopic_der"}: {unit: "ratio", defaults: provenanceDefaults{valueType: "rated", source: "datasheet_pdf", method: "transcribed", family: attestationFamilyMelanopic}}, + {sheet: "color_states", field: "lumens"}: {unit: "lm", defaults: provenanceDefaults{valueType: "rated", source: "datasheet_pdf", method: "transcribed", family: attestationFamilyPhotometric}}, + {sheet: "color_states", field: "efficacy_lm_per_w"}: {unit: "lm/W", defaults: provenanceDefaults{valueType: "rated", source: "datasheet_pdf", method: "transcribed", family: attestationFamilyPhotometric}}, + {sheet: "emission_channels", field: "peak_wavelength_nm"}: {unit: "nm", defaults: provenanceDefaults{valueType: "rated", source: "datasheet_pdf", method: "transcribed", family: attestationFamilyPhotometric}}, + {sheet: "emission_channels", field: "lumens"}: {unit: "lm", defaults: provenanceDefaults{valueType: "rated", source: "datasheet_pdf", method: "transcribed", family: attestationFamilyPhotometric}}, {sheet: "alpha_opic", field: "melanopic_der"}: { unit: "ratio", defaults: provenanceDefaults{valueType: "rated", source: "datasheet_pdf", method: "extracted", family: attestationFamilyMelanopic}, diff --git a/tools/validator/internal/sheet/supplementary_provenance_test.go b/tools/validator/internal/sheet/supplementary_provenance_test.go index 9f6b2a2..7b9a6dd 100644 --- a/tools/validator/internal/sheet/supplementary_provenance_test.go +++ b/tools/validator/internal/sheet/supplementary_provenance_test.go @@ -24,6 +24,17 @@ func TestSupplementaryValueColumnTableIsExact(t *testing.T) { family attestationFamily requiredValueType string }{ + {sheet: "color_states", field: "measured_cct_k"}: {unit: "K", family: attestationFamilyPhotometric}, + {sheet: "color_states", field: "duv"}: {unit: "", family: attestationFamilyPhotometric}, + {sheet: "color_states", field: "cri_ra"}: {unit: "", family: attestationFamilyPhotometric}, + {sheet: "color_states", field: "cri_r9"}: {unit: "", family: attestationFamilyPhotometric}, + {sheet: "color_states", field: "tm_30_rf"}: {unit: "", family: attestationFamilyPhotometric}, + {sheet: "color_states", field: "tm_30_rg"}: {unit: "", family: attestationFamilyPhotometric}, + {sheet: "color_states", field: "melanopic_der"}: {unit: "ratio", family: attestationFamilyMelanopic}, + {sheet: "color_states", field: "lumens"}: {unit: "lm", family: attestationFamilyPhotometric}, + {sheet: "color_states", field: "efficacy_lm_per_w"}: {unit: "lm/W", family: attestationFamilyPhotometric}, + {sheet: "emission_channels", field: "peak_wavelength_nm"}: {unit: "nm", family: attestationFamilyPhotometric}, + {sheet: "emission_channels", field: "lumens"}: {unit: "lm", family: attestationFamilyPhotometric}, {sheet: "tm_30_hue_bins", field: "rf_h"}: {family: attestationFamilyPhotometric}, {sheet: "alpha_opic", field: "melanopic_der"}: {unit: "ratio", family: attestationFamilyMelanopic}, {sheet: "alpha_opic", field: "efficacy"}: {unitColumn: "efficacy_unit", allowedUnits: map[string]bool{"W/lm": true, "mW/lm": true}, family: attestationFamilyMelanopic}, From 25050928778e05ff575ef782020d06d0b6117bde Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:10:41 -0400 Subject: [PATCH 06/10] Prepare ULC 1.18.0 release documentation and converter version --- CHANGELOG.md | 28 +++++++++++++++++++ README.md | 2 +- ROADMAP.md | 4 ++- mappings/pim/akeneo.md | 2 +- mappings/pim/custom-pim.md | 2 +- mappings/pim/salsify.md | 2 +- mappings/pim/sap.md | 2 +- tools/validator/internal/sheet/specversion.go | 2 +- 8 files changed, 37 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b70f60f..fdac6f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,34 @@ Releases are automated. To ship a release: For emergency manual releases (bypassing the PR flow), trigger the `Release on merge` workflow manually via `workflow_dispatch`, providing the version input. +## 1.18.0 (2026-10-16) + +This release adds published color states and emitter channels beside the tested baseline for tunable-white, dim-to-warm and color-mixing luminaires. The shared resolver returns the baseline first and then the other published states, preserving each state's own colorimetry, spectrum, alpha-opic metrics and flux. Builder 0.11.0 projects the two numeric white-range endpoints and applies the new endpoint grading rows. + +### For consumers + +The existing top-level measurement blocks remain the tested baseline. `color_tuning.baseline_role` identifies its role; `states` lists only other states. The nominal CCT bin takes precedence over measured CCT, with `configuration.tested_conditions.nominal_cct_at_test` first for the baseline. The generated `index.cct_range_low_k` and `index.cct_range_high_k` appear together only when both endpoints resolve. A tunable range filter also checks `color_tunability` for `tunable_white` or `dim_to_warm`; a dark-sky filter uses the larger of the high endpoint and the numeric tested CCT. + +Stored tunable-white or dim-to-warm records without both endpoints fall below Standard after rebuilding the index. Both endpoint CCTs are required for Standard; endpoint CRI Ra and melanopic DER are required for Full. Existing grading rows continue to read the baseline only. Settings, dim-level points, channels and endpoint spectra never add a gate. Static-white records and records without a color-tunability capability cannot carry a `color_tuning` block. Every stored index should be rebuilt for builder 0.11.0. + +### Grading + +The public gating table gains two pointer rows: Standard `/color_tuning` (`datasheet_pdf`, `identity`) and Full `/color_tuning/states` (`datasheet_pdf`, `CIE 13.3`). The scope manifest remains `scope_version` 2.0.0, with 71 gating rows, 73 enrichment rows, two observation rows and 61 gating pointer rows. The two new enrichment rows ask for channels on color-mixing records and endpoint spectra when a color-tuning block is present. The eight existing examples keep their grades and change only `index.builder_version`; the two color-mixing validate golden pairs gain the channel nudge. + +### Validator and workbook + +`color-tuning/inconsistent` reports duplicate state or channel identifiers, a lone endpoint, a reversed range, a state outside a tunable-white or dim-to-warm range, repeated CCT/dim-level/case-folded-label identities, and shared CCT/dim-level states missing a label. The schema prevents repeating the baseline endpoint and permits at most one listed end of each role. Dim-level roles require a percent in (0, 100]; setting roles forbid a percent. + +The workbook adds `color_states` and `emission_channels`, the three `color_tuning_baseline_*` records columns, and `state_id`/`channel_id` on the spectrum sheet. Blank spectrum identifiers still target the baseline. Each new numeric column has provenance companions and defaults to rated, datasheet_pdf, transcribed. The converter refuses malformed or ambiguous state/channel rows with sheet, row and record context. Use a 1.18.0 converter or newer for the new sheets; older converters ignore them. Newly converted records default to 1.18.0. + +### Examples + +A ninth example records the public Gotham EVO 4-inch tunable-white Atmosphere Range cutsheet: the tested 3500 K baseline and its published 1800 K and 4300 K endpoints, with flux, CRI and R9. Its source URL and SHA-256 identify the public cutsheet; the PDF is not committed. The off-bin 4300 K endpoint uses a rated `measured_cct_k` value. Unpublished melanopic DER and spectra remain absent. + +### Documentation + +The methodology defines color-state identity, the single CCT rule, baseline ownership and endpoint grading. The workbook README and converter design document describe the two authoring sheets and per-state spectra. The README, roadmap and PIM guide examples advance to 1.18.0. + ## 1.17.0 (2026-10-14) This release adds a workbook authoring path for the TM-30 hue-bin local fidelity values a record carries at `colorimetry.tm_30.rf_h_per_bin`, so the Full row that asks for them can be met from a workbook. The schema structure and taxonomy do not change; the array's description is corrected to name the one value its entries carry. No required set, conformance grade, achievement state, or generated index member changes. The builder remains 0.10.0, and all eight published examples remain byte-identical. diff --git a/README.md b/README.md index 3144b1b..2173d9f 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ To check the current state of manufacturer adoption (which manufacturers have pu ## Project status -The current release is `1.17.0`. ULC computes two orthogonal axes rather than declaring them: the reference builder grades each record against three completeness grades (`core`, `standard`, `full`) above an `incomplete` floor, and computes the Product Achievements axis (per theme, `none`, `claimed`, or `documented`) beside it, stamping both into the generated index. The toolchain ships the split schema and taxonomy, the drift-guard tooling, the reference command-line validator and compiler (`ulc`) with schema validation, builder parity, and source-file hash verification, the deterministic `from-sheet` workbook-to-record converter, the fill-in workbook template under `templates/workbook/`, and the PIM platform mapping guides under `mappings/pim/`. See `CHANGELOG.md` for the full release history. +The current release is `1.18.0`. ULC computes two orthogonal axes rather than declaring them: the reference builder grades each record against three completeness grades (`core`, `standard`, `full`) above an `incomplete` floor, and computes the Product Achievements axis (per theme, `none`, `claimed`, or `documented`) beside it, stamping both into the generated index. The toolchain ships the split schema and taxonomy, the drift-guard tooling, the reference command-line validator and compiler (`ulc`) with schema validation, builder parity, and source-file hash verification, the deterministic `from-sheet` workbook-to-record converter, the fill-in workbook template under `templates/workbook/`, and the PIM platform mapping guides under `mappings/pim/`. See `CHANGELOG.md` for the full release history. The specification will continue to evolve based on real-world use, industry feedback, and alignment with adjacent standards. See `CHANGELOG.md` for release notes. diff --git a/ROADMAP.md b/ROADMAP.md index 00ad4a4..08e99dd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,7 +27,7 @@ for the next major. Pre-1.0 releases generally aimed for additive changes; compatibility-tightening changes occurred only when documented in the changelog (as with the v0.3.0 `cri_tier` enum tightening). -## Active version: v1.17.x +## Active version: v1.18.x The current line, and ULC's first formal backward-compatibility commitment. v1.0.0 adds **Product Achievements**, a second computed axis alongside data @@ -164,6 +164,8 @@ v1.16.0 moves the LM-79 attestation from Standard to Full and zonal lumens from v1.17.0 gives the TM-30 hue-bin local fidelity values a workbook authoring path: one `tm_30_hue_bins` sheet whose sixteen rows convert to `colorimetry.tm_30.rf_h_per_bin`, so the Full row that asks for them can be met from a workbook. The converter, template and one schema description change; the schema structure, taxonomy, required sets, grades, achievements, generated index, and eight published examples do not. Builder version 0.10.0 remains current. +v1.18.0 adds a referenced tested baseline, published luminaire color states and emitter channels. One resolver owns state reading and CCT selection; builder 0.11.0 projects both white-range endpoints. Tunable-white and dim-to-warm records need both endpoints for Standard and CRI Ra plus melanopic DER at each end for Full. Channels and endpoint spectra are enrichment. The workbook adds two sheets and state/channel spectrum routing. The eight existing examples keep their grades; a ninth public tunable-white example demonstrates the range and off-bin endpoint authoring. Scope contract 2.0.0 remains current. + The 1.0 milestone is defined by the two computed axes and the compatibility commitment, justified by the additive-only release history and a validator hardened against real cutsheets. Two items that earlier drafts framed as 1.0 diff --git a/mappings/pim/akeneo.md b/mappings/pim/akeneo.md index f5f8117..5049489 100644 --- a/mappings/pim/akeneo.md +++ b/mappings/pim/akeneo.md @@ -145,7 +145,7 @@ function emitUlcFromAkeneo(Product $product, Variant $variant): ?string { return null; } $record = [ - 'ulc_version' => '1.17.0', + 'ulc_version' => '1.18.0', 'record_id' => slug("{$product->getBrand()}-{$product->getIdentifier()}-{$variant->getScenarioSlug()}"), 'record_status' => 'active', 'record_status_as_of' => date('Y-m-d'), // the emit/edit date; drives record-relative expiry diff --git a/mappings/pim/custom-pim.md b/mappings/pim/custom-pim.md index 2275439..5a8c109 100644 --- a/mappings/pim/custom-pim.md +++ b/mappings/pim/custom-pim.md @@ -205,7 +205,7 @@ def emit_ulc(session: Session): family = build_family(product, primary_category, mounting) for scenario in product.photometric_scenarios: record = { - "ulc_version": "1.17.0", + "ulc_version": "1.18.0", "record_id": slug(f"{product.manufacturer}-{product.model}-{scenario.slug}"), "record_status": "active", "record_status_as_of": date.today().isoformat(), # emit/edit date; drives record-relative expiry diff --git a/mappings/pim/salsify.md b/mappings/pim/salsify.md index e5803e2..1027b2a 100644 --- a/mappings/pim/salsify.md +++ b/mappings/pim/salsify.md @@ -152,7 +152,7 @@ Accessory-type classification requires another PIM-to-ULC enum mapping (junction # Illustrative pseudocode, not a working implementation. def emit_ulc_from_salsify(product, scenario): record = { - "ulc_version": "1.17.0", + "ulc_version": "1.18.0", "record_id": f"{product.brand_slug}-{product.sku_slug}-{scenario.slug}", "record_status": "active", "record_status_as_of": date.today().isoformat(), # emit/edit date; drives record-relative expiry diff --git a/mappings/pim/sap.md b/mappings/pim/sap.md index 7a8c2ad..391d5dd 100644 --- a/mappings/pim/sap.md +++ b/mappings/pim/sap.md @@ -166,7 +166,7 @@ In practice, ABAP handles data extraction (CDS views or SAP CAP/RAP) and a Pytho def emit_ulc_from_sap(material, variant, characteristics, dms_docs): primary_category = CLASS_TO_ULC_CATEGORY[material['class']] record = { - "ulc_version": "1.17.0", + "ulc_version": "1.18.0", "record_id": slug(f"{material['brand_slug']}-{material['matnr']}-{variant['scenario_slug']}"), "record_status": "active", "record_status_as_of": date.today().isoformat(), # emit/edit date; drives record-relative expiry diff --git a/tools/validator/internal/sheet/specversion.go b/tools/validator/internal/sheet/specversion.go index 252578b..6135d28 100644 --- a/tools/validator/internal/sheet/specversion.go +++ b/tools/validator/internal/sheet/specversion.go @@ -9,7 +9,7 @@ import ( // SpecVersion is the current released ULC specification version stamped into // newly converted records. It is a compile-time converter policy, independent // of the CLI build version, so source and packaged builds stamp the same value. -const SpecVersion = "1.17.0" +const SpecVersion = "1.18.0" func specificationVersionGreater(candidate, bound string) (bool, error) { parse := func(value string) ([3]int, error) { From 7d8b995df02c7aecd3dc3fa5496576244d34d779 Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:08:17 -0400 Subject: [PATCH 07/10] Add a public LCVA tunable-white example with endpoint spectra --- CHANGELOG.md | 4 +- examples/README.md | 4 +- ...0-277-48-13w-4000k-10x10-dmx-rdm-na-nh.ulc | 1189 +++++++++++++++++ ...-277-48-13w-4000k-10x10-dmx-rdm-na-nh.json | 339 +++++ .../internal/completeness/scope_test.go | 1 + ...-277-48-13w-4000k-10x10-dmx-rdm-na-nh.json | 282 ++++ ...0-277-48-13w-4000k-10x10-dmx-rdm-na-nh.txt | 33 + .../index/achievements_parity_test.go | 1 + 8 files changed, 1850 insertions(+), 3 deletions(-) create mode 100644 examples/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.ulc create mode 100644 tools/validator/cmd/ulc/testdata/scope-golden/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.json create mode 100644 tools/validator/internal/completeness/testdata/golden/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.json create mode 100644 tools/validator/internal/completeness/testdata/golden/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index fdac6f2..dc00490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ Releases are automated. To ship a release: For emergency manual releases (bypassing the PR flow), trigger the `Release on merge` workflow manually via `workflow_dispatch`, providing the version input. -## 1.18.0 (2026-10-16) +## 1.18.0 (2026-10-17) This release adds published color states and emitter channels beside the tested baseline for tunable-white, dim-to-warm and color-mixing luminaires. The shared resolver returns the baseline first and then the other published states, preserving each state's own colorimetry, spectrum, alpha-opic metrics and flux. Builder 0.11.0 projects the two numeric white-range endpoints and applies the new endpoint grading rows. @@ -42,7 +42,7 @@ The workbook adds `color_states` and `emission_channels`, the three `color_tunin ### Examples -A ninth example records the public Gotham EVO 4-inch tunable-white Atmosphere Range cutsheet: the tested 3500 K baseline and its published 1800 K and 4300 K endpoints, with flux, CRI and R9. Its source URL and SHA-256 identify the public cutsheet; the PDF is not committed. The off-bin 4300 K endpoint uses a rated `measured_cct_k` value. Unpublished melanopic DER and spectra remain absent. +A ninth example records one public Lumenpulse Lumencore Linear Graze LCVA configuration: 48 in, 13 W/ft, 10x10, DMX/RDM, NA/NH. The 4000 K IES baseline matches the sheet's explicit 4000 K table and unscaled file. Its 1800 K and 6500 K endpoints carry the measured CRI and TM-30 values printed in the supplied reports, plus the original 401-sample spectra. One claimed LM-79 attestation anchors measured values under the existing evidence-family policy. The sheet, IES, endpoint reports and spectral files are individually hashed source documents; none is committed. Endpoint flux, melanopic DER and through-optics status remain absent because the files do not state them. The example is schema-valid at the incomplete floor, with its ordinary missing-data roadmap. ### Documentation diff --git a/examples/README.md b/examples/README.md index 470e594..bebbc32 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,6 +16,7 @@ Source files (the original PDF datasheets, IES photometric files, and related do | `cooper-sure-lites-lpx7sd.ulc` | Exit-sign class: internally illuminated, self-powered | The sign profile authored from a spec sheet (no IES): illumination mode, legend color, a UL 924 listing, and an integral-battery `emergency` block with the standard battery trio (NiCd, 90-minute, self-diagnostic). Held at core by two real disclosure gaps the LPX sheet does not publish (letter height and self-powered input wattage) | | `cooper-sure-lites-es61src.ulc` | Exit-sign class: internally illuminated, AC only | An edge-lit AC-only sign at standard: 6-inch letters, single face, no chevron, and 3.8 W published input power. The battery trio correctly skips because `power_source` is `ac_only` (the hasIntegralBattery negative in a real record) | | `cooper-atlite-auxswhsd.ulc` | Exit-sign class: internally illuminated, self-powered | A self-powered NYC-compliant edge-lit sign at standard with 8-inch letters and the battery trio met; its `ul_924` attestation is backed by two attached UL Certificates of Compliance, carried as `compliance_documents` source files | +| `lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.ulc` | C: one public tunable-white scenario | 48 in LCVA at 13 W/ft, 10x10, DMX/RDM, NA/NH: the 4000 K IES baseline, 1800 K and 6500 K endpoint TM-30/CRI values and measured spectra, with one claimed LM-79 anchor and individually hashed source documents; unstated endpoint flux, melanopic DER and through-optics status remain absent | ## Conformance grades and roadmap @@ -31,6 +32,7 @@ Each record is graded by the reference validator, which computes a conformance g | `cooper-sure-lites-es61src.ulc` | standard | **full** needs test-report-backed sign-face luminance (test report, UL 924), the UL 924 test-report depth that anchors the sign full tier | | `cooper-atlite-auxswhsd.ulc` | standard | **full** needs test-report-backed sign-face luminance (test report, UL 924) | | `cooper-sure-lites-lpx7sd.ulc` | core | **standard** needs `legend_height` (datasheet, NFPA 101 / IBC) and `input_power_w` (datasheet, UL 924), the two figures the LPX sheet does not publish. **full** additionally needs test-report-backed sign-face luminance (test report, UL 924) | +| `lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.ulc` | incomplete | **core** still needs the ordinary selection fields not authored in this focused color-state example, including distribution, efficacy, voltage, region, use, environment, mounting and safety listing. The validator emits the complete Standard and Full roadmap; the new Full endpoint row additionally needs melanopic DER at both ends, which is not published. | The architectural standard records sit one tier below full for the same structural reason: full certifies the complete accredited-report depth, measurement uncertainty, applied corrections, TM-30 fidelity, a measured spectrum as data, and a method-backed maintenance projection, none of which a published cutsheet carries. A record may already satisfy one or two of those gates (Erco carries a TM-21 maintenance projection), but none carries the whole set. Three records are held at core, each by its own honest gap: the Vode pendant publishes no MacAdam SDCM step, which standard requires of a white-light fixture, and the Cooper LPX exit sign publishes neither its letter height nor a self-powered input wattage. Selux is held at core because its cutsheet states no lumen-maintenance hours. The two Cooper edge-lit signs that reach standard (ES and AUX) are one tier below full for the sign-specific reason: full asks for a test-report-backed face luminance, the UL 924 lab depth a spec sheet does not carry. @@ -40,7 +42,7 @@ The two Lumenpulse facade records reach standard despite publishing no dimming m ## How the records were produced -These reference records were authored by hand from each manufacturer's publicly-available spec sheet and photometric files, to serve as canonical, real-data fixtures. Values flow from the IES file header and the spec sheet into the record's deep blocks, and the `index` block is generated by `ulc build-index` from those blocks. Manufacturers producing records at scale do not hand-write JSON: they fill the `templates/workbook/` spreadsheet for the deterministic `ulc from-sheet` converter, or emit from a PIM (see `docs/how-it-works.md`). Every `value_type: measured` field in the record points to an attestation record via `provenance.attestation_ref`; every scaled or simulated value points to a base attestation via `provenance.base_attestation_ref`. +The eight original reference records were authored by hand from each manufacturer's publicly-available spec sheet and photometric files. The LCVA color-state example was authored through the workbook converter, including both 401-sample endpoint spectra; its public product URL was added to the generated cutsheet references as metadata. All nine serve as canonical, real-data fixtures. Values flow from the IES file header and the spec sheet into the record's deep blocks, and the `index` block is generated by `ulc build-index` from those blocks. Manufacturers producing records at scale do not hand-write JSON: they fill the `templates/workbook/` spreadsheet for the deterministic `ulc from-sheet` converter, or emit from a PIM (see `docs/how-it-works.md`). Every `value_type: measured` field in the record points to an attestation record via `provenance.attestation_ref`; every scaled or simulated value points to a base attestation via `provenance.base_attestation_ref`. SHA-256 hashes in the `source_files[]` array reference the specific file versions used during authoring. When a manufacturer reissues a cutsheet or regenerates an IES, the hash will differ from what is stored in the record. diff --git a/examples/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.ulc b/examples/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.ulc new file mode 100644 index 0000000..67cef92 --- /dev/null +++ b/examples/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.ulc @@ -0,0 +1,1189 @@ +{ + "attestations": [ + { + "attestation_id": "lm79_lcva_105740498crt_018", + "issued_date": "2024-07-09", + "program": "lm_79", + "source_document_ref": { + "filename": "LCVA-120_277-48-13W-4000K-10x10-DMX_RDM-NA-NH.ies", + "sha256": "da7e026f3e7e2942cec121f8980d8fc293db439d0fcbc7d046b0b97362b28379" + }, + "status": "claimed", + "test_laboratory": "Intertek Cortland", + "test_report_id": "105740498CRT-018", + "value_type": "measured", + "verification": { + "notes": "Tested by Intertek Cortland under report 105740498CRT-018; IES header spells the laboratory Intertek Corltand. The sheet states photometric performance measured in compliance with IESNA LM-79-24. Endpoint documents: TM-30_LCVA 1800K.pdf (printed date 9/5/2024), TM-30_LCVA 6500K.pdf (printed date 8/5/2024), LCVA-120_277-48-13W-(1800K)-10x10-DMX_RDM-NA-NH.txt and LCVA-120_277-48-13W-(6500K)-10x10-DMX_RDM-NA-NH.txt. The reports and spectral tables are source documents, not separate value anchors. No measured-through-optics statement is supplied.", + "type": "unconditional" + } + } + ], + "color_tuning": { + "baseline_role": "setting", + "states": [ + { + "colorimetry": { + "cri_r9": { + "provenance": { + "attestation_ref": "lm79_lcva_105740498crt_018", + "conflict_notes": "Values transcribed from TM-30_LCVA 1800K.pdf. The sheet gives typical CRI bounds rather than exact endpoint values.", + "method": "transcribed", + "source": "test_report" + }, + "value": 44, + "value_type": "measured" + }, + "cri_ra": { + "provenance": { + "attestation_ref": "lm79_lcva_105740498crt_018", + "conflict_notes": "Values transcribed from TM-30_LCVA 1800K.pdf. The sheet gives typical CRI bounds rather than exact endpoint values.", + "method": "transcribed", + "source": "test_report" + }, + "value": 89, + "value_type": "measured" + }, + "nominal_cct_k": "1800", + "spectral_power_distribution": { + "provenance": { + "attestation_ref": "lm79_lcva_105740498crt_018", + "conflict_notes": "Published spectral text file LCVA-120_277-48-13W-(1800K)-10x10-DMX_RDM-NA-NH.txt; absolute units and measured-through-optics status are not supplied.", + "method": "transcribed", + "source": "test_report" + }, + "source_kind": "exchange_file", + "unit": "relative", + "value_type": "measured", + "values": [ + 0, + 0.002103365, + 0.00010016, + 0, + 0.001702724, + 0.002203526, + 0.001302083, + 0.001101763, + 0.003004808, + 0.001903045, + 0.003305288, + 0.001903045, + 0, + 0.002504006, + 0.00010016, + 0.004407051, + 0.000300481, + 0.002604167, + 0.00010016, + 0.001702724, + 0.002303686, + 0.000400641, + 0.000600962, + 0.000801282, + 0.002504006, + 0.002704327, + 0.000801282, + 0.001402244, + 0.001702724, + 0.002003205, + 0.003405449, + 0.002704327, + 0.001502404, + 0.002103365, + 0.001702724, + 0.002403846, + 0.002804487, + 0.002904647, + 0.003104968, + 0.002804487, + 0.00390625, + 0.004707532, + 0.004707532, + 0.005508814, + 0.005709135, + 0.006911058, + 0.007011218, + 0.008213141, + 0.009415064, + 0.009815705, + 0.010416667, + 0.01181891, + 0.013621795, + 0.015224359, + 0.016826923, + 0.018028846, + 0.01963141, + 0.021534455, + 0.023838141, + 0.026241987, + 0.029246795, + 0.032051282, + 0.03515625, + 0.037660256, + 0.043569712, + 0.049078526, + 0.054887821, + 0.062099359, + 0.067508013, + 0.075420673, + 0.080328526, + 0.087840545, + 0.09364984, + 0.095853365, + 0.097455929, + 0.098257212, + 0.095953526, + 0.093349359, + 0.090344551, + 0.086137821, + 0.082231571, + 0.077724359, + 0.075020032, + 0.072616186, + 0.072115385, + 0.071414263, + 0.070913462, + 0.069611378, + 0.069611378, + 0.069110577, + 0.068108974, + 0.066806891, + 0.065805288, + 0.064803686, + 0.063100962, + 0.06239984, + 0.061598558, + 0.060596955, + 0.060697115, + 0.062700321, + 0.063301282, + 0.065504808, + 0.06650641, + 0.069811699, + 0.071414263, + 0.07411859, + 0.076923077, + 0.078425481, + 0.081229968, + 0.085036058, + 0.086538462, + 0.089242788, + 0.093048878, + 0.095452724, + 0.099058494, + 0.101762821, + 0.104767628, + 0.10947516, + 0.112780449, + 0.116586538, + 0.119691506, + 0.123096955, + 0.126201923, + 0.130308494, + 0.133213141, + 0.135316506, + 0.139022436, + 0.142728365, + 0.143930288, + 0.147736378, + 0.151442308, + 0.153545673, + 0.156951122, + 0.159054487, + 0.161959135, + 0.16416266, + 0.16786859, + 0.169170673, + 0.178685897, + 0.175180288, + 0.177283654, + 0.179286859, + 0.181290064, + 0.184595353, + 0.186498397, + 0.187700321, + 0.191606571, + 0.193810096, + 0.194310897, + 0.198818109, + 0.200120192, + 0.200721154, + 0.204326923, + 0.207932692, + 0.209735577, + 0.213141026, + 0.21474359, + 0.216846955, + 0.218950321, + 0.220653045, + 0.225060096, + 0.225961538, + 0.229567308, + 0.235176282, + 0.236177885, + 0.239282853, + 0.243489583, + 0.245092147, + 0.248397436, + 0.250901442, + 0.256109776, + 0.259014423, + 0.265124199, + 0.268028846, + 0.273036859, + 0.277043269, + 0.283153045, + 0.285857372, + 0.292267628, + 0.298577724, + 0.304186699, + 0.307892628, + 0.314002404, + 0.3203125, + 0.327323718, + 0.333233173, + 0.33974359, + 0.349258814, + 0.35556891, + 0.36338141, + 0.37109375, + 0.380508814, + 0.389923878, + 0.39853766, + 0.407451923, + 0.417668269, + 0.427183494, + 0.437800481, + 0.44911859, + 0.459535256, + 0.472255609, + 0.483373397, + 0.494791667, + 0.508613782, + 0.5234375, + 0.537960737, + 0.549078526, + 0.561298077, + 0.579927885, + 0.588641827, + 0.607171474, + 0.619991987, + 0.633213141, + 0.65244391, + 0.667467949, + 0.681089744, + 0.698517628, + 0.709735577, + 0.724659455, + 0.739383013, + 0.755408654, + 0.764523237, + 0.783153045, + 0.797876603, + 0.81260016, + 0.822415865, + 0.833633814, + 0.8515625, + 0.861778846, + 0.880909455, + 0.884114583, + 0.89833734, + 0.906951122, + 0.917367788, + 0.928986378, + 0.940905449, + 0.942908654, + 0.94911859, + 0.958633814, + 0.965344551, + 0.97255609, + 0.979066506, + 0.982471955, + 0.987479968, + 0.990785256, + 0.994391026, + 0.996895032, + 0.996794872, + 0.999799679, + 0.997195513, + 0.999499199, + 1, + 0.994290865, + 0.990885417, + 0.993189103, + 0.984775641, + 0.98427484, + 0.98046875, + 0.977363782, + 0.968249199, + 0.961438301, + 0.959034455, + 0.951822917, + 0.950120192, + 0.940404647, + 0.929387019, + 0.92197516, + 0.913762019, + 0.902944712, + 0.895432692, + 0.888221154, + 0.875901442, + 0.866486378, + 0.858473558, + 0.849559295, + 0.836237981, + 0.825620994, + 0.810396635, + 0.800580929, + 0.788261218, + 0.77734375, + 0.766125801, + 0.754106571, + 0.740985577, + 0.728966346, + 0.715745192, + 0.702423878, + 0.691706731, + 0.680188301, + 0.66786859, + 0.655148237, + 0.646233974, + 0.633213141, + 0.618589744, + 0.609575321, + 0.595653045, + 0.584034455, + 0.573016827, + 0.562900641, + 0.548778045, + 0.539763622, + 0.528345353, + 0.517427885, + 0.50390625, + 0.495492788, + 0.482772436, + 0.47265625, + 0.459334936, + 0.453926282, + 0.439202724, + 0.431590545, + 0.420172276, + 0.412560096, + 0.402544071, + 0.390925481, + 0.383413462, + 0.373597756, + 0.36338141, + 0.353766026, + 0.345252404, + 0.339242788, + 0.328625801, + 0.320512821, + 0.313000801, + 0.306189904, + 0.297275641, + 0.290464744, + 0.282652244, + 0.274439103, + 0.267027244, + 0.260216346, + 0.254907853, + 0.245793269, + 0.238882212, + 0.233673878, + 0.22666266, + 0.222255609, + 0.217347756, + 0.208934295, + 0.205028045, + 0.19931891, + 0.194911859, + 0.189503205, + 0.183092949, + 0.176883013, + 0.172075321, + 0.16786859, + 0.163561699, + 0.158653846, + 0.153445513, + 0.150340545, + 0.146634615, + 0.142528045, + 0.136418269, + 0.132411859, + 0.130408654, + 0.125901442, + 0.121794872, + 0.119190705, + 0.115584936, + 0.112179487, + 0.109174679, + 0.105769231, + 0.102864583, + 0.099158654, + 0.097355769, + 0.093449519, + 0.092147436, + 0.087640224, + 0.085236378, + 0.083333333, + 0.080428686, + 0.079827724, + 0.076121795, + 0.075921474, + 0.070913462, + 0.070012019, + 0.068108974, + 0.066105769, + 0.064302885, + 0.06260016, + 0.060997596, + 0.059395032, + 0.057491987, + 0.054887821, + 0.053385417, + 0.052483974, + 0.051081731, + 0.048978365, + 0.047375801, + 0.046073718, + 0.044871795, + 0.042467949, + 0.041466346, + 0.040264423, + 0.039863782, + 0.038661859, + 0.037560096, + 0.036057692, + 0.034154647, + 0.03525641 + ], + "wavelength_start_nm": 380, + "wavelength_step_nm": 1 + }, + "tm_30": { + "rf": { + "provenance": { + "attestation_ref": "lm79_lcva_105740498crt_018", + "conflict_notes": "Values transcribed from TM-30_LCVA 1800K.pdf. The sheet gives typical CRI bounds rather than exact endpoint values.", + "method": "transcribed", + "source": "test_report" + }, + "value": 87, + "value_type": "measured" + }, + "rg": { + "provenance": { + "attestation_ref": "lm79_lcva_105740498crt_018", + "conflict_notes": "Values transcribed from TM-30_LCVA 1800K.pdf. The sheet gives typical CRI bounds rather than exact endpoint values.", + "method": "transcribed", + "source": "test_report" + }, + "value": 102, + "value_type": "measured" + } + } + }, + "role": "range_low_end", + "state_id": "warm-end" + }, + { + "colorimetry": { + "cri_r9": { + "provenance": { + "attestation_ref": "lm79_lcva_105740498crt_018", + "conflict_notes": "Values transcribed from TM-30_LCVA 6500K.pdf. The sheet gives typical CRI bounds rather than exact endpoint values.", + "method": "transcribed", + "source": "test_report" + }, + "value": 81, + "value_type": "measured" + }, + "cri_ra": { + "provenance": { + "attestation_ref": "lm79_lcva_105740498crt_018", + "conflict_notes": "Values transcribed from TM-30_LCVA 6500K.pdf. The sheet gives typical CRI bounds rather than exact endpoint values.", + "method": "transcribed", + "source": "test_report" + }, + "value": 96, + "value_type": "measured" + }, + "nominal_cct_k": "6500", + "spectral_power_distribution": { + "provenance": { + "attestation_ref": "lm79_lcva_105740498crt_018", + "conflict_notes": "Published spectral text file LCVA-120_277-48-13W-(6500K)-10x10-DMX_RDM-NA-NH.txt; absolute units and measured-through-optics status are not supplied.", + "method": "transcribed", + "source": "test_report" + }, + "source_kind": "exchange_file", + "unit": "relative", + "value_type": "measured", + "values": [ + 0.004207574, + 0.004107393, + 0.002805049, + 0.00320577, + 0.003806852, + 0.002304147, + 0.002704869, + 0.002304147, + 0.003406131, + 0.002103787, + 0.003305951, + 0.001302344, + 0.003305951, + 0.002905229, + 0.002103787, + 0.002003606, + 0.002905229, + 0.002404328, + 0.002304147, + 0.003506311, + 0.003606492, + 0.00310559, + 0.004007213, + 0.002905229, + 0.004107393, + 0.004407934, + 0.004708475, + 0.005009016, + 0.005610098, + 0.007413344, + 0.008314967, + 0.008815869, + 0.010819475, + 0.01242236, + 0.014526147, + 0.016028852, + 0.01863354, + 0.022039671, + 0.026046884, + 0.029953917, + 0.034462032, + 0.039170507, + 0.044179523, + 0.050390703, + 0.057002605, + 0.065017031, + 0.073231817, + 0.082448407, + 0.092766981, + 0.104387898, + 0.116609898, + 0.130534963, + 0.146764176, + 0.163895011, + 0.183129633, + 0.202564616, + 0.227810058, + 0.255560008, + 0.283209778, + 0.313764777, + 0.352634743, + 0.396613905, + 0.435483871, + 0.490382689, + 0.546884392, + 0.611801242, + 0.679723502, + 0.74373873, + 0.811961531, + 0.871468644, + 0.923963134, + 0.971648968, + 0.992386295, + 1, + 0.989280705, + 0.961931477, + 0.925666199, + 0.879382889, + 0.825686235, + 0.772290122, + 0.727509517, + 0.693448207, + 0.659186536, + 0.633941094, + 0.613404127, + 0.593067522, + 0.576938489, + 0.55850531, + 0.53997195, + 0.52153877, + 0.500701262, + 0.479863755, + 0.462632739, + 0.43818874, + 0.421057904, + 0.405830495, + 0.395712282, + 0.386295332, + 0.381787217, + 0.376978561, + 0.374373873, + 0.374474053, + 0.373071529, + 0.375475857, + 0.375175316, + 0.376277299, + 0.37948307, + 0.383690643, + 0.383891004, + 0.386696053, + 0.389400922, + 0.389501102, + 0.397014626, + 0.399118413, + 0.403325987, + 0.406231216, + 0.411841314, + 0.415447806, + 0.41985574, + 0.423161691, + 0.426768183, + 0.430775396, + 0.432979363, + 0.434982969, + 0.439591264, + 0.442997395, + 0.442195953, + 0.44750551, + 0.448607493, + 0.450010018, + 0.45071128, + 0.453516329, + 0.455019034, + 0.455720297, + 0.455720297, + 0.457924264, + 0.45982769, + 0.460929673, + 0.4567221, + 0.460528952, + 0.462232018, + 0.462131837, + 0.461931477, + 0.464435985, + 0.465638149, + 0.463434182, + 0.464836706, + 0.464135444, + 0.465037067, + 0.464836706, + 0.462532559, + 0.46593869, + 0.466239231, + 0.466339411, + 0.46593869, + 0.466840313, + 0.46593869, + 0.465738329, + 0.464836706, + 0.464936886, + 0.465337608, + 0.463334001, + 0.464636345, + 0.463233821, + 0.463133641, + 0.463434182, + 0.462632739, + 0.460929673, + 0.458324985, + 0.458525346, + 0.458625526, + 0.456321378, + 0.455720297, + 0.453015428, + 0.451913444, + 0.451011821, + 0.449509116, + 0.447204969, + 0.446603887, + 0.445902625, + 0.445301543, + 0.440993789, + 0.442396313, + 0.439791625, + 0.437888199, + 0.43508315, + 0.433179724, + 0.433580445, + 0.432678822, + 0.432979363, + 0.430875576, + 0.430074133, + 0.427469445, + 0.427469445, + 0.427569625, + 0.427569625, + 0.425666199, + 0.425566019, + 0.424363855, + 0.426267281, + 0.423862953, + 0.423862953, + 0.422260068, + 0.422560609, + 0.422560609, + 0.421658986, + 0.422360248, + 0.421458626, + 0.423362052, + 0.420657183, + 0.424363855, + 0.420557003, + 0.420757363, + 0.422059707, + 0.421358445, + 0.422560609, + 0.42286115, + 0.42276097, + 0.422059707, + 0.420456822, + 0.422460429, + 0.420757363, + 0.421859347, + 0.422660789, + 0.421558806, + 0.420056101, + 0.418853937, + 0.419455019, + 0.418553396, + 0.417551593, + 0.416850331, + 0.415547986, + 0.414746544, + 0.412943298, + 0.4134442, + 0.412542577, + 0.410038069, + 0.409036265, + 0.405930675, + 0.403927069, + 0.403726708, + 0.402123823, + 0.39821679, + 0.397014626, + 0.39501102, + 0.393207774, + 0.39210579, + 0.387798036, + 0.386295332, + 0.383089561, + 0.37978361, + 0.377880184, + 0.375776398, + 0.372971348, + 0.369565217, + 0.36716089, + 0.36425566, + 0.360849529, + 0.359146464, + 0.354037267, + 0.352234021, + 0.347325185, + 0.345021038, + 0.339711481, + 0.338108595, + 0.332698858, + 0.328090563, + 0.323882989, + 0.320076137, + 0.31496694, + 0.311560809, + 0.307653777, + 0.303346023, + 0.297435384, + 0.292025646, + 0.287317171, + 0.282708876, + 0.27769986, + 0.272390303, + 0.266579844, + 0.261270287, + 0.256762172, + 0.251753156, + 0.245341615, + 0.241033861, + 0.236024845, + 0.230414747, + 0.226808255, + 0.221097976, + 0.21618914, + 0.211781206, + 0.207172911, + 0.202163895, + 0.196754157, + 0.191644961, + 0.188539371, + 0.182929273, + 0.179322781, + 0.175015027, + 0.170907634, + 0.166399519, + 0.162292126, + 0.158385093, + 0.152975356, + 0.149569225, + 0.146363454, + 0.142656782, + 0.138048487, + 0.135543979, + 0.131737127, + 0.128431176, + 0.124624324, + 0.121719094, + 0.117812062, + 0.114906832, + 0.111801242, + 0.108495292, + 0.105790423, + 0.103085554, + 0.099779603, + 0.096974554, + 0.094269685, + 0.092065718, + 0.089160489, + 0.086255259, + 0.083550391, + 0.081747145, + 0.079342817, + 0.076637948, + 0.075135243, + 0.072129834, + 0.070727309, + 0.068623522, + 0.066419555, + 0.06491685, + 0.062412342, + 0.061210178, + 0.059306752, + 0.057303146, + 0.05560008, + 0.053997195, + 0.052294129, + 0.050991785, + 0.049589261, + 0.048687638, + 0.046984572, + 0.045582048, + 0.044179523, + 0.042676818, + 0.041274294, + 0.03997195, + 0.038769786, + 0.037567622, + 0.035964737, + 0.035363655, + 0.034361851, + 0.033460228, + 0.031857343, + 0.031456622, + 0.030154278, + 0.029453015, + 0.028050491, + 0.027449409, + 0.026647966, + 0.025746343, + 0.02484472, + 0.024243639, + 0.023742737, + 0.022540573, + 0.02153877, + 0.022039671, + 0.020937688, + 0.020336606, + 0.019935885, + 0.019134442, + 0.018733721, + 0.017631737, + 0.017331196, + 0.017331196, + 0.016730114, + 0.016229213, + 0.01552795, + 0.014726508, + 0.014926868, + 0.014025245, + 0.013524344, + 0.013524344, + 0.013323983, + 0.01242236, + 0.012021639, + 0.011821278, + 0.011019836, + 0.010619114, + 0.010518934, + 0.010318573, + 0.010118213, + 0.009817672, + 0.009717491, + 0.00921659, + 0.009016229, + 0.008715688 + ], + "wavelength_start_nm": 380, + "wavelength_step_nm": 1 + }, + "tm_30": { + "rf": { + "provenance": { + "attestation_ref": "lm79_lcva_105740498crt_018", + "conflict_notes": "Values transcribed from TM-30_LCVA 6500K.pdf. The sheet gives typical CRI bounds rather than exact endpoint values.", + "method": "transcribed", + "source": "test_report" + }, + "value": 91, + "value_type": "measured" + }, + "rg": { + "provenance": { + "attestation_ref": "lm79_lcva_105740498crt_018", + "conflict_notes": "Values transcribed from TM-30_LCVA 6500K.pdf. The sheet gives typical CRI bounds rather than exact endpoint values.", + "method": "transcribed", + "source": "test_report" + }, + "value": 98, + "value_type": "measured" + } + } + }, + "role": "range_high_end", + "state_id": "cool-end" + } + ] + }, + "colorimetry": { + "cri_r9": { + "provenance": { + "method": "transcribed", + "source": "datasheet_pdf" + }, + "value": 77, + "value_type": "rated" + }, + "cri_ra": { + "provenance": { + "method": "transcribed", + "source": "datasheet_pdf" + }, + "value": 96, + "value_type": "rated" + }, + "nominal_cct_k": "4000", + "tm_30": { + "rf": { + "provenance": { + "method": "transcribed", + "source": "datasheet_pdf" + }, + "value": 91, + "value_type": "rated" + }, + "rg": { + "provenance": { + "method": "transcribed", + "source": "datasheet_pdf" + }, + "value": 98, + "value_type": "rated" + } + } + }, + "configuration": { + "photometric_scenario_id": "lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh", + "scenario_label": "48 in, 13 W/ft, 10x10 optic, DMX/RDM, NA mount, NH; 4000 K baseline. The IES names 4000K and has candela multiplier 1, matching the sheet page 1 footnote and page 6 table. The separate page 5 table is explicitly 3000K.", + "source_ies_ref": "LCVA-120_277-48-13W-4000K-10x10-DMX_RDM-NA-NH.ies", + "tested_axes": { + "color_tunability": "tunable_white" + }, + "tested_conditions": { + "nominal_cct_at_test": "4000" + } + }, + "electrical": { + "driver_protocol": "dmx_rdm", + "input_power_w": { + "provenance": { + "attestation_ref": "lm79_lcva_105740498crt_018", + "method": "extracted", + "source": "ies" + }, + "unit": "W", + "value": 51.49, + "value_type": "measured" + } + }, + "index": { + "achievements": { + "documented_count": 0, + "themes": { + "circularity": { + "evidence_present": false, + "programs": [], + "source_attestation_ids": [], + "state": "none" + }, + "dark_sky": { + "evidence_present": false, + "programs": [], + "source_attestation_ids": [], + "state": "none" + }, + "domestic_content": { + "evidence_present": false, + "programs": [], + "source_attestation_ids": [], + "state": "none" + }, + "embodied_carbon": { + "evidence_present": false, + "programs": [], + "source_attestation_ids": [], + "state": "none" + }, + "emergency": { + "evidence_present": false, + "programs": [], + "source_attestation_ids": [], + "state": "none" + }, + "energy": { + "evidence_present": false, + "programs": [], + "source_attestation_ids": [], + "state": "none" + }, + "material_health": { + "evidence_present": false, + "programs": [], + "source_attestation_ids": [], + "state": "none" + } + } + }, + "attestation_programs": [ + "lm_79" + ], + "builder_version": "0.11.0", + "catalog_model": "LCVA", + "catalog_number": "lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh", + "cct_range_high_k": 6500, + "cct_range_low_k": 1800, + "color_tunability": "tunable_white", + "conformance_level": "incomplete", + "dimming_protocols": [ + "dmx_rdm" + ], + "display_name": "48 in, 13 W/ft, 10x10 optic, DMX/RDM, NA mount, NH; 4000 K baseline. The IES names 4000K and has candela multiplier 1, matching the sheet page 1 footnote and page 6 table. The separate page 5 table is explicitly 3000K.", + "manufacturer_slug": "lumenpulse", + "nominal_cct_k": "4000", + "nominal_cri_ra": 96, + "nominal_efficacy_lm_per_w": 67.41, + "nominal_input_power_w": 51.49, + "nominal_total_lumens": 3471, + "primary_category": "grazer", + "restricted_substances_declared": [], + "search_keywords": [ + "48 in, 13 W/ft, 10x10 optic, DMX/RDM, NA mount, NH; 4000 K baseline. The IES names 4000K and has candela multiplier 1, matching the sheet page 1 footnote and page 6 table. The separate page 5 table is explicitly 3000K.", + "Lumencore Linear Graze", + "Lumencore Linear Graze Dynamic White" + ], + "source_file_types_present": [ + "datasheet_pdf", + "ies", + "manufacturer_data_export", + "test_report" + ], + "x-ulc-generated": true + }, + "photometry": { + "maximum_intensity_cd": { + "provenance": { + "method": "transcribed", + "source": "datasheet_pdf" + }, + "unit": "cd", + "value": 33594, + "value_type": "rated" + }, + "total_luminous_flux_lm": { + "provenance": { + "method": "transcribed", + "source": "datasheet_pdf" + }, + "unit": "lm", + "value": 3471, + "value_type": "rated" + } + }, + "product_family": { + "catalog_line": "Lumencore Linear Graze", + "catalog_model": "LCVA", + "cutsheet": { + "filename": "lumenpulse-lumencore-linear-graze-dynamic-white-lcva-complete-specsheet-na-imperial.pdf", + "revision_label": "2026.01.27", + "sha256": "02d38623df9c2c90ef54566d82cd7fce89b139c899c5bb31287ac03d18c78e16", + "url": "https://www.lumenpulse.com/products/5280" + }, + "family_description": "Public source: https://www.lumenpulse.com/products/5280. Custom dynamic-white range 1800 K to 6500 K for one fixed configuration. Endpoint flux and melanopic DER are not stated and remain absent. Typical CRI bounds in the sheet are not exact values; endpoint CRI values are transcribed from the supplied TM-30 reports. Raw spectral values retain the published relative scale; through-optics status is not stated.", + "family_display_name": "Lumencore Linear Graze Dynamic White", + "family_id": "lumenpulse-lumencore-linear-graze-lcva", + "manufacturer": { + "display_name": "Lumenpulse", + "slug": "lumenpulse" + }, + "primary_category": "grazer", + "shape": "linear" + }, + "record_id": "lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh", + "record_status": "active", + "record_status_as_of": "2026-10-17", + "source_files": [ + { + "file_type": "ies", + "reference": { + "filename": "LCVA-120_277-48-13W-4000K-10x10-DMX_RDM-NA-NH.ies", + "sha256": "da7e026f3e7e2942cec121f8980d8fc293db439d0fcbc7d046b0b97362b28379" + } + }, + { + "file_type": "test_report", + "reference": { + "filename": "TM-30_LCVA 1800K.pdf", + "sha256": "fa6b84287f8606fd013cc696e7664ef0805d3515ce9f47da7dbcae8c8e7dcd0e" + } + }, + { + "file_type": "test_report", + "reference": { + "filename": "TM-30_LCVA 6500K.pdf", + "sha256": "774bb9fda9717ef3be679e00dc55fc8873c5a85d7f4f86e034b7bfdc69f4fba2" + } + }, + { + "file_type": "manufacturer_data_export", + "reference": { + "filename": "LCVA-120_277-48-13W-(1800K)-10x10-DMX_RDM-NA-NH.txt", + "sha256": "8ea2b3cc1b8f4ab2845f2bbf1942edc5468ce704b67fd32bfba5543ac65bc198" + } + }, + { + "file_type": "manufacturer_data_export", + "reference": { + "filename": "LCVA-120_277-48-13W-(6500K)-10x10-DMX_RDM-NA-NH.txt", + "sha256": "dbdc12d2f3161619fc27d4946307f2e4b56a66a0e91583cabc32a7787a23b4b8" + } + }, + { + "file_type": "datasheet_pdf", + "reference": { + "filename": "lumenpulse-lumencore-linear-graze-dynamic-white-lcva-complete-specsheet-na-imperial.pdf", + "revision_label": "2026.01.27", + "sha256": "02d38623df9c2c90ef54566d82cd7fce89b139c899c5bb31287ac03d18c78e16", + "url": "https://www.lumenpulse.com/products/5280" + } + } + ], + "ulc_version": "1.18.0" +} diff --git a/tools/validator/cmd/ulc/testdata/scope-golden/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.json b/tools/validator/cmd/ulc/testdata/scope-golden/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.json new file mode 100644 index 0000000..5be7353 --- /dev/null +++ b/tools/validator/cmd/ulc/testdata/scope-golden/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.json @@ -0,0 +1,339 @@ +{ + "scope_version": "2.0.0", + "cli_version": "CLI_VERSION", + "record_id": "lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh", + "ulc_version": "1.18.0", + "blocks": [ + "attestations", + "color_tuning", + "colorimetry", + "configuration", + "corrections_applied", + "electrical", + "instrumentation", + "lumen_maintenance_luminaire", + "lumen_maintenance_package", + "operating_point", + "photometry", + "product_family", + "test_conditions", + "uncertainty" + ], + "items": [ + { + "tier": "core", + "kind": "pointer", + "path": "/colorimetry/cri_ra", + "source_document": "datasheet_pdf", + "standard": "CIE 13.3" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/colorimetry/nominal_cct_k", + "source_document": "datasheet_pdf", + "standard": "ANSI C78.377" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/configuration/tested_axes/color_tunability", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/electrical/driver_protocol", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/electrical/input_power_w", + "source_document": "ies", + "standard": "LM-79" + }, + { + "tier": "core", + "kind": "choice", + "path": "/electrical/input_voltage_v (or input_voltage_class)", + "source_document": "datasheet_pdf", + "standard": "LM-79" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/photometry/distribution_type", + "source_document": "ies", + "standard": "LM-79" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/photometry/luminaire_efficacy_lm_per_w", + "source_document": "ies", + "standard": "LM-79" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/photometry/total_luminous_flux_lm", + "source_document": "ies", + "standard": "LM-79" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/product_family/catalog_model", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/product_family/cutsheet", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/product_family/environment_rating", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/product_family/indoor_outdoor", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/product_family/manufacturer/display_name", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/product_family/manufacturer/slug", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/product_family/mounting_types", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/product_family/primary_category", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/product_family/secondary_function", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/product_family/shape", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "core", + "kind": "pointer", + "path": "/product_family/technical_region", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "core", + "kind": "requirement", + "path": "safety listing (UL/cUL/ETL/CSA for NA; CE/ENEC/IEC 60598 otherwise)", + "source_document": "compliance_documents", + "standard": "UL 1598 / IEC 60598" + }, + { + "tier": "standard", + "kind": "pointer", + "path": "/color_tuning", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "standard", + "kind": "pointer", + "path": "/colorimetry/sdcm_step", + "source_document": "datasheet_pdf", + "standard": "ANSI C78.377" + }, + { + "tier": "standard", + "kind": "pointer", + "path": "/configuration/tested_axes/cri_tier", + "source_document": "datasheet_pdf", + "standard": "CIE 13.3" + }, + { + "tier": "standard", + "kind": "pointer", + "path": "/electrical/control_gear_type", + "source_document": "datasheet_pdf", + "standard": "LM-79" + }, + { + "tier": "standard", + "kind": "pointer", + "path": "/instrumentation/measurement_regime", + "source_document": "ies", + "standard": "LM-79" + }, + { + "tier": "standard", + "kind": "choice", + "path": "/lumen_maintenance_luminaire (or /lumen_maintenance_package)", + "source_document": "datasheet_pdf", + "standard": "TM-21" + }, + { + "tier": "standard", + "kind": "pointer", + "path": "/photometry/beam_angle_deg", + "source_document": "ies", + "standard": "LM-79" + }, + { + "tier": "standard", + "kind": "pointer", + "path": "/photometry/maximum_intensity_cd", + "source_document": "ies", + "standard": "LM-79" + }, + { + "tier": "standard", + "kind": "pointer", + "path": "/photometry/photometric_coordinate_system", + "source_document": "ies", + "standard": "LM-75" + }, + { + "tier": "standard", + "kind": "pointer", + "path": "/photometry/symmetry_type", + "source_document": "ies", + "standard": "LM-75" + }, + { + "tier": "standard", + "kind": "pointer", + "path": "/photometry/zonal_lumens", + "source_document": "ies", + "standard": "LM-79" + }, + { + "tier": "standard", + "kind": "pointer", + "path": "/product_family/shared_mechanical/housing_material", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "standard", + "kind": "pointer", + "path": "/product_family/shared_mechanical/lens_material", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "tier": "standard", + "kind": "pointer", + "path": "/test_conditions/photometry_basis", + "source_document": "ies", + "standard": "LM-79" + }, + { + "tier": "full", + "kind": "pointer", + "path": "/color_tuning/states", + "source_document": "datasheet_pdf", + "standard": "CIE 13.3" + }, + { + "tier": "full", + "kind": "pointer", + "path": "/colorimetry/tm_30/rf", + "source_document": "test_report", + "standard": "TM-30" + }, + { + "tier": "full", + "kind": "pointer", + "path": "/colorimetry/tm_30/rf_h_per_bin", + "source_document": "test_report", + "standard": "TM-30" + }, + { + "tier": "full", + "kind": "pointer", + "path": "/corrections_applied", + "source_document": "test_report", + "standard": "LM-79" + }, + { + "tier": "full", + "kind": "pointer", + "path": "/operating_point", + "source_document": "test_report", + "standard": "LM-79" + }, + { + "tier": "full", + "kind": "pointer", + "path": "/uncertainty", + "source_document": "test_report", + "standard": "LM-79 / GUM" + }, + { + "tier": "full", + "kind": "requirement", + "path": "LM-79 attestation", + "source_document": "test_report", + "standard": "LM-79" + }, + { + "tier": "full", + "kind": "requirement", + "path": "instrumentation depth (goniometer/lab)", + "source_document": "test_report", + "standard": "LM-79 / LM-75" + }, + { + "tier": "full", + "kind": "requirement", + "path": "measured spectral power distribution as data (through the optics; table or exchange file)", + "source_document": "test_report", + "standard": "LM-79 / TM-27" + }, + { + "tier": "full", + "kind": "requirement", + "path": "method-backed lumen maintenance (TM-21 hours or TM-28)", + "source_document": "test_report", + "standard": "LM-80 / TM-21 / TM-28" + } + ] +} diff --git a/tools/validator/internal/completeness/scope_test.go b/tools/validator/internal/completeness/scope_test.go index 77e9b87..927436a 100644 --- a/tools/validator/internal/completeness/scope_test.go +++ b/tools/validator/internal/completeness/scope_test.go @@ -420,6 +420,7 @@ func TestScopeCorpusShape(t *testing.T) { items, core, std, full int blocks []string }{ + {"lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.ulc", 45, 21, 14, 10, withColor("colorimetry", "color_tuning")}, {"cooper-atlite-auxswhsd.ulc", 25, 15, 9, 1, sign}, {"cooper-sure-lites-es61src.ulc", 22, 15, 6, 1, sign}, {"cooper-sure-lites-lpx7sd.ulc", 25, 15, 9, 1, sign}, diff --git a/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.json b/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.json new file mode 100644 index 0000000..b499bef --- /dev/null +++ b/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.json @@ -0,0 +1,282 @@ +{ + "findings": [ + { + "level": "INFO", + "code": "achievements/summary", + "message": "achievements: 0 documented, 0 claimed", + "path": "/index/achievements" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"full\", add /color_tuning/states (from datasheet_pdf, per CIE 13.3)", + "path": "/color_tuning/states", + "next_conformance_level": "full", + "source_document": "datasheet_pdf", + "standard": "CIE 13.3" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"standard\", add /colorimetry/sdcm_step (from datasheet_pdf, per ANSI C78.377)", + "path": "/colorimetry/sdcm_step", + "next_conformance_level": "standard", + "source_document": "datasheet_pdf", + "standard": "ANSI C78.377" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"full\", add /colorimetry/tm_30/rf_h_per_bin (from test_report, per TM-30)", + "path": "/colorimetry/tm_30/rf_h_per_bin", + "next_conformance_level": "full", + "source_document": "test_report", + "standard": "TM-30" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"standard\", add /configuration/tested_axes/cri_tier (from datasheet_pdf, per CIE 13.3)", + "path": "/configuration/tested_axes/cri_tier", + "next_conformance_level": "standard", + "source_document": "datasheet_pdf", + "standard": "CIE 13.3" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"full\", add /corrections_applied (from test_report, per LM-79)", + "path": "/corrections_applied", + "next_conformance_level": "full", + "source_document": "test_report", + "standard": "LM-79" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"standard\", add /electrical/control_gear_type (from datasheet_pdf, per LM-79)", + "path": "/electrical/control_gear_type", + "next_conformance_level": "standard", + "source_document": "datasheet_pdf", + "standard": "LM-79" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"core\", add /electrical/input_voltage_v (or input_voltage_class) (from datasheet_pdf, per LM-79)", + "path": "/electrical/input_voltage_v (or input_voltage_class)", + "next_conformance_level": "core", + "source_document": "datasheet_pdf", + "standard": "LM-79" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"standard\", add /instrumentation/measurement_regime (from ies, per LM-79)", + "path": "/instrumentation/measurement_regime", + "next_conformance_level": "standard", + "source_document": "ies", + "standard": "LM-79" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"standard\", add /lumen_maintenance_luminaire (or /lumen_maintenance_package) (from datasheet_pdf, per TM-21)", + "path": "/lumen_maintenance_luminaire (or /lumen_maintenance_package)", + "next_conformance_level": "standard", + "source_document": "datasheet_pdf", + "standard": "TM-21" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"full\", add /operating_point (from test_report, per LM-79)", + "path": "/operating_point", + "next_conformance_level": "full", + "source_document": "test_report", + "standard": "LM-79" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"standard\", add /photometry/beam_angle_deg (from ies, per LM-79)", + "path": "/photometry/beam_angle_deg", + "next_conformance_level": "standard", + "source_document": "ies", + "standard": "LM-79" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"core\", add /photometry/distribution_type (from ies, per LM-79)", + "path": "/photometry/distribution_type", + "next_conformance_level": "core", + "source_document": "ies", + "standard": "LM-79" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"core\", add /photometry/luminaire_efficacy_lm_per_w (from ies, per LM-79)", + "path": "/photometry/luminaire_efficacy_lm_per_w", + "next_conformance_level": "core", + "source_document": "ies", + "standard": "LM-79" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"standard\", add /photometry/photometric_coordinate_system (from ies, per LM-75)", + "path": "/photometry/photometric_coordinate_system", + "next_conformance_level": "standard", + "source_document": "ies", + "standard": "LM-75" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"standard\", add /photometry/symmetry_type (from ies, per LM-75)", + "path": "/photometry/symmetry_type", + "next_conformance_level": "standard", + "source_document": "ies", + "standard": "LM-75" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"standard\", add /photometry/zonal_lumens (from ies, per LM-79)", + "path": "/photometry/zonal_lumens", + "next_conformance_level": "standard", + "source_document": "ies", + "standard": "LM-79" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"core\", add /product_family/environment_rating (from datasheet_pdf, per identity)", + "path": "/product_family/environment_rating", + "next_conformance_level": "core", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"core\", add /product_family/indoor_outdoor (from datasheet_pdf, per identity)", + "path": "/product_family/indoor_outdoor", + "next_conformance_level": "core", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"core\", add /product_family/mounting_types (from datasheet_pdf, per identity)", + "path": "/product_family/mounting_types", + "next_conformance_level": "core", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"core\", add /product_family/secondary_function (from datasheet_pdf, per identity)", + "path": "/product_family/secondary_function", + "next_conformance_level": "core", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"standard\", add /product_family/shared_mechanical/housing_material (from datasheet_pdf, per identity)", + "path": "/product_family/shared_mechanical/housing_material", + "next_conformance_level": "standard", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"standard\", add /product_family/shared_mechanical/lens_material (from datasheet_pdf, per identity)", + "path": "/product_family/shared_mechanical/lens_material", + "next_conformance_level": "standard", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"core\", add /product_family/technical_region (from datasheet_pdf, per identity)", + "path": "/product_family/technical_region", + "next_conformance_level": "core", + "source_document": "datasheet_pdf", + "standard": "identity" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"standard\", add /test_conditions/photometry_basis (from ies, per LM-79)", + "path": "/test_conditions/photometry_basis", + "next_conformance_level": "standard", + "source_document": "ies", + "standard": "LM-79" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"full\", add /uncertainty (from test_report, per LM-79 / GUM)", + "path": "/uncertainty", + "next_conformance_level": "full", + "source_document": "test_report", + "standard": "LM-79 / GUM" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"full\", add instrumentation depth (goniometer/lab) (from test_report, per LM-79 / LM-75)", + "path": "instrumentation depth (goniometer/lab)", + "next_conformance_level": "full", + "source_document": "test_report", + "standard": "LM-79 / LM-75" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"full\", add measured spectral power distribution as data (through the optics; table or exchange file) (from test_report, per LM-79 / TM-27)", + "path": "measured spectral power distribution as data (through the optics; table or exchange file)", + "next_conformance_level": "full", + "source_document": "test_report", + "standard": "LM-79 / TM-27" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"full\", add method-backed lumen maintenance (TM-21 hours or TM-28) (from test_report, per LM-80 / TM-21 / TM-28)", + "path": "method-backed lumen maintenance (TM-21 hours or TM-28)", + "next_conformance_level": "full", + "source_document": "test_report", + "standard": "LM-80 / TM-21 / TM-28" + }, + { + "level": "INFO", + "code": "conformance/gap", + "message": "to reach \"core\", add safety listing (UL/cUL/ETL/CSA for NA; CE/ENEC/IEC 60598 otherwise) (from compliance_documents, per UL 1598 / IEC 60598)", + "path": "safety listing (UL/cUL/ETL/CSA for NA; CE/ENEC/IEC 60598 otherwise)", + "next_conformance_level": "core", + "source_document": "compliance_documents", + "standard": "UL 1598 / IEC 60598" + }, + { + "level": "INFO", + "code": "conformance/level", + "message": "this record achieves conformance level \"incomplete\"", + "path": "/index/conformance_level" + } + ], + "summary": { + "errors": 0, + "warnings": 0, + "infos": 31 + } +} diff --git a/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.txt b/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.txt new file mode 100644 index 0000000..f51843c --- /dev/null +++ b/tools/validator/internal/completeness/testdata/golden/lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.txt @@ -0,0 +1,33 @@ +INFO achievements/summary at /index/achievements: achievements: 0 documented, 0 claimed +INFO conformance/gap at /color_tuning/states: to reach "full", add /color_tuning/states (from datasheet_pdf, per CIE 13.3) +INFO conformance/gap at /colorimetry/sdcm_step: to reach "standard", add /colorimetry/sdcm_step (from datasheet_pdf, per ANSI C78.377) +INFO conformance/gap at /colorimetry/tm_30/rf_h_per_bin: to reach "full", add /colorimetry/tm_30/rf_h_per_bin (from test_report, per TM-30) +INFO conformance/gap at /configuration/tested_axes/cri_tier: to reach "standard", add /configuration/tested_axes/cri_tier (from datasheet_pdf, per CIE 13.3) +INFO conformance/gap at /corrections_applied: to reach "full", add /corrections_applied (from test_report, per LM-79) +INFO conformance/gap at /electrical/control_gear_type: to reach "standard", add /electrical/control_gear_type (from datasheet_pdf, per LM-79) +INFO conformance/gap at /electrical/input_voltage_v (or input_voltage_class): to reach "core", add /electrical/input_voltage_v (or input_voltage_class) (from datasheet_pdf, per LM-79) +INFO conformance/gap at /instrumentation/measurement_regime: to reach "standard", add /instrumentation/measurement_regime (from ies, per LM-79) +INFO conformance/gap at /lumen_maintenance_luminaire (or /lumen_maintenance_package): to reach "standard", add /lumen_maintenance_luminaire (or /lumen_maintenance_package) (from datasheet_pdf, per TM-21) +INFO conformance/gap at /operating_point: to reach "full", add /operating_point (from test_report, per LM-79) +INFO conformance/gap at /photometry/beam_angle_deg: to reach "standard", add /photometry/beam_angle_deg (from ies, per LM-79) +INFO conformance/gap at /photometry/distribution_type: to reach "core", add /photometry/distribution_type (from ies, per LM-79) +INFO conformance/gap at /photometry/luminaire_efficacy_lm_per_w: to reach "core", add /photometry/luminaire_efficacy_lm_per_w (from ies, per LM-79) +INFO conformance/gap at /photometry/photometric_coordinate_system: to reach "standard", add /photometry/photometric_coordinate_system (from ies, per LM-75) +INFO conformance/gap at /photometry/symmetry_type: to reach "standard", add /photometry/symmetry_type (from ies, per LM-75) +INFO conformance/gap at /photometry/zonal_lumens: to reach "standard", add /photometry/zonal_lumens (from ies, per LM-79) +INFO conformance/gap at /product_family/environment_rating: to reach "core", add /product_family/environment_rating (from datasheet_pdf, per identity) +INFO conformance/gap at /product_family/indoor_outdoor: to reach "core", add /product_family/indoor_outdoor (from datasheet_pdf, per identity) +INFO conformance/gap at /product_family/mounting_types: to reach "core", add /product_family/mounting_types (from datasheet_pdf, per identity) +INFO conformance/gap at /product_family/secondary_function: to reach "core", add /product_family/secondary_function (from datasheet_pdf, per identity) +INFO conformance/gap at /product_family/shared_mechanical/housing_material: to reach "standard", add /product_family/shared_mechanical/housing_material (from datasheet_pdf, per identity) +INFO conformance/gap at /product_family/shared_mechanical/lens_material: to reach "standard", add /product_family/shared_mechanical/lens_material (from datasheet_pdf, per identity) +INFO conformance/gap at /product_family/technical_region: to reach "core", add /product_family/technical_region (from datasheet_pdf, per identity) +INFO conformance/gap at /test_conditions/photometry_basis: to reach "standard", add /test_conditions/photometry_basis (from ies, per LM-79) +INFO conformance/gap at /uncertainty: to reach "full", add /uncertainty (from test_report, per LM-79 / GUM) +INFO conformance/gap at instrumentation depth (goniometer/lab): to reach "full", add instrumentation depth (goniometer/lab) (from test_report, per LM-79 / LM-75) +INFO conformance/gap at measured spectral power distribution as data (through the optics; table or exchange file): to reach "full", add measured spectral power distribution as data (through the optics; table or exchange file) (from test_report, per LM-79 / TM-27) +INFO conformance/gap at method-backed lumen maintenance (TM-21 hours or TM-28): to reach "full", add method-backed lumen maintenance (TM-21 hours or TM-28) (from test_report, per LM-80 / TM-21 / TM-28) +INFO conformance/gap at safety listing (UL/cUL/ETL/CSA for NA; CE/ENEC/IEC 60598 otherwise): to reach "core", add safety listing (UL/cUL/ETL/CSA for NA; CE/ENEC/IEC 60598 otherwise) (from compliance_documents, per UL 1598 / IEC 60598) +INFO conformance/level at /index/conformance_level: this record achieves conformance level "incomplete" + +OK -- lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.ulc: 0 errors, 0 warnings, 31 infos. diff --git a/tools/validator/internal/index/achievements_parity_test.go b/tools/validator/internal/index/achievements_parity_test.go index a812781..47c91b8 100644 --- a/tools/validator/internal/index/achievements_parity_test.go +++ b/tools/validator/internal/index/achievements_parity_test.go @@ -88,6 +88,7 @@ func TestRealExampleAchievementStates(t *testing.T) { conformance string } want := map[string]row{ + "lumenpulse-lumencore-linear-graze-lcva-120-277-48-13w-4000k-10x10-dmx-rdm-na-nh.ulc": {"none", "none", "none", "none", "none", "none", "none", 0, []string{}, "incomplete"}, "cooper-atlite-auxswhsd.ulc": {"documented", "none", "none", "none", "none", "none", "none", 1, []string{}, "standard"}, "cooper-sure-lites-es61src.ulc": {"claimed", "none", "none", "none", "none", "none", "none", 0, []string{}, "standard"}, "cooper-sure-lites-lpx7sd.ulc": {"claimed", "claimed", "none", "none", "none", "none", "none", 0, []string{}, "core"}, From 4593182e824da6797db3e574afdbebea118d2538 Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:15:40 -0400 Subject: [PATCH 08/10] Clarify spectrum groups and release documentation claims --- CHANGELOG.md | 2 +- ROADMAP.md | 2 +- templates/workbook/README.md | 17 ++++++++++------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc00490..75ab615 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,7 +38,7 @@ The public gating table gains two pointer rows: Standard `/color_tuning` (`datas `color-tuning/inconsistent` reports duplicate state or channel identifiers, a lone endpoint, a reversed range, a state outside a tunable-white or dim-to-warm range, repeated CCT/dim-level/case-folded-label identities, and shared CCT/dim-level states missing a label. The schema prevents repeating the baseline endpoint and permits at most one listed end of each role. Dim-level roles require a percent in (0, 100]; setting roles forbid a percent. -The workbook adds `color_states` and `emission_channels`, the three `color_tuning_baseline_*` records columns, and `state_id`/`channel_id` on the spectrum sheet. Blank spectrum identifiers still target the baseline. Each new numeric column has provenance companions and defaults to rated, datasheet_pdf, transcribed. The converter refuses malformed or ambiguous state/channel rows with sheet, row and record context. Use a 1.18.0 converter or newer for the new sheets; older converters ignore them. Newly converted records default to 1.18.0. +The workbook adds `color_states` and `emission_channels`, the three `color_tuning_baseline_*` records columns, and `state_id`/`channel_id` on the spectrum sheet. Blank spectrum identifiers still target the baseline. Each new provenanced measurement column has provenance companions and defaults to rated, datasheet_pdf, transcribed. Dim-level percentages are scalar metadata without provenance companions. The converter refuses malformed or ambiguous state/channel rows with sheet, row and record context. Use a 1.18.0 converter or newer for the new sheets; older converters ignore them. Newly converted records default to 1.18.0. ### Examples diff --git a/ROADMAP.md b/ROADMAP.md index 08e99dd..c2c689f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,7 +164,7 @@ v1.16.0 moves the LM-79 attestation from Standard to Full and zonal lumens from v1.17.0 gives the TM-30 hue-bin local fidelity values a workbook authoring path: one `tm_30_hue_bins` sheet whose sixteen rows convert to `colorimetry.tm_30.rf_h_per_bin`, so the Full row that asks for them can be met from a workbook. The converter, template and one schema description change; the schema structure, taxonomy, required sets, grades, achievements, generated index, and eight published examples do not. Builder version 0.10.0 remains current. -v1.18.0 adds a referenced tested baseline, published luminaire color states and emitter channels. One resolver owns state reading and CCT selection; builder 0.11.0 projects both white-range endpoints. Tunable-white and dim-to-warm records need both endpoints for Standard and CRI Ra plus melanopic DER at each end for Full. Channels and endpoint spectra are enrichment. The workbook adds two sheets and state/channel spectrum routing. The eight existing examples keep their grades; a ninth public tunable-white example demonstrates the range and off-bin endpoint authoring. Scope contract 2.0.0 remains current. +v1.18.0 adds a referenced tested baseline, published luminaire color states and emitter channels. One resolver owns state reading and CCT selection; builder 0.11.0 projects both white-range endpoints. Tunable-white and dim-to-warm records need both endpoints for Standard and CRI Ra plus melanopic DER at each end for Full. Channels and endpoint spectra are enrichment. The workbook adds two sheets and state/channel spectrum routing. The eight existing examples keep their grades; a ninth public tunable-white example demonstrates the range, endpoint spectra and reported color-quality values. Scope contract 2.0.0 remains current. The 1.0 milestone is defined by the two computed axes and the compatibility commitment, justified by the additive-only release history and a validator diff --git a/templates/workbook/README.md b/templates/workbook/README.md index b6eb48e..137c126 100644 --- a/templates/workbook/README.md +++ b/templates/workbook/README.md @@ -213,18 +213,21 @@ references is required for each row. ## Spectrum sheet Use `spectral_power_distribution` for a sampled spectrum. Each row needs -`record_id`, numeric `wavelength_nm`, and numeric `value`. Supply at least two -rows per record in strictly ascending order at one uniform wavelength step. The -converter derives the start and step from those rows and refuses duplicate, -descending, or off-grid wavelengths. For a chart or exchange file with irregular -samples, resample the curve to one step before authoring it. +`record_id`, numeric `wavelength_nm`, and numeric `value`. A spectrum group is +identified by (`record_id`, `state_id`, `channel_id`); blank state and channel +identifiers select the baseline. Supply at least two rows per group in strictly +ascending order at one uniform wavelength step. The converter derives each +group's start and step independently and refuses duplicate, descending, or +off-grid wavelengths within that group. For a chart or exchange file with +irregular samples, resample that spectrum to one step before authoring it. Set `unit` to `mW/nm`, `W/nm`, or `relative`, and set `source_kind` to `laboratory_table`, `digitized_chart`, or `exchange_file`. For a luminaire-level measurement through its optics, set `measured_through_optics` to `TRUE`. These cells and `conflict_notes` may be filled on the first row or repeated; two -different non-blank values for the same record are refused. The six `value__*` -companions follow the same rule and describe the whole spectrum. The default +different non-blank values within the same spectrum group are refused. The six +`value__*` companions follow the same rule and describe that group's spectrum. +Different groups may carry different metadata and provenance. The default is measured, from a test report, transcribed, and linked to the record's single LM-79 attestation. For a digitized cutsheet chart, set `value__value_type=rated` and `value__prov_source=datasheet_pdf`. For a TM-27 From af4b3275e12e1b36284a1b6ba3ce74bd1459e98d Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:34:57 -0400 Subject: [PATCH 09/10] Document published color-state mapping for PIM emitters --- mappings/pim/README.md | 25 ++++++++++++++++++++++++- mappings/pim/akeneo.md | 2 ++ mappings/pim/custom-pim.md | 2 ++ mappings/pim/salsify.md | 2 ++ mappings/pim/sap.md | 2 ++ 5 files changed, 32 insertions(+), 1 deletion(-) diff --git a/mappings/pim/README.md b/mappings/pim/README.md index 9788b15..9d2fd95 100644 --- a/mappings/pim/README.md +++ b/mappings/pim/README.md @@ -19,7 +19,7 @@ These guides describe, per PIM platform, how the typical PIM data model maps int ## Shared translation concerns -Every PIM mapping faces the same six problems. The per-PIM guides cover each system's specifics; the high-level pattern is common. +Every PIM mapping faces the same seven problems. The per-PIM guides cover each system's specifics; the high-level pattern is common. ### 1. Product identity and the ULC record-per-scenario model @@ -116,6 +116,29 @@ Both `ulc build-index` and `ulc validate` are file-path CLIs: they read from and Because the index is a projection, re-run `ulc build-index` after any deep-block edit so the stored index stays parity-valid, and re-stamp `record_status_as_of` to the edit date so record-relative expiry is evaluated against the current review rather than a stale one. (Re-stamping only changes the index when the new date crosses an attestation's expiry boundary; its own job is accurate expiry dating, not parity.) Schedule a periodic `ulc validate --expiry` over the published corpus to preview attestation and declaration expiry before a re-stamp makes a downgrade normative. +### 7. Published color states and emitter channels + +For a tunable-white, dim-to-warm or color-mixing luminaire, map the published operating states and emitter channels into `color_tuning`. Keep the tested state in the existing top-level `colorimetry`, `alpha_opic_metrics` and `photometry` blocks. The `states` array contains only the other published states; do not duplicate the tested baseline. A static-white record, or a record with no color-tunability capability, carries no `color_tuning` block. + +| PIM data | Authoritative ULC field | Workbook authoring equivalent | +|---|---|---| +| Tested state's role | `color_tuning.baseline_role` | `records.color_tuning_baseline_role` | +| Tested state's printed name, when needed | `color_tuning.baseline_label` | `records.color_tuning_baseline_label` | +| Tested state's published dim level | `color_tuning.baseline_dim_level_percent` | `records.color_tuning_baseline_dim_level_percent` | +| Other published luminaire states | `color_tuning.states[]` | One `color_states` row per state | +| Published emitter channels | `color_tuning.channels[]` | One `emission_channels` row per channel | +| Baseline spectrum | `colorimetry.spectral_power_distribution` | `spectral_power_distribution` rows with both identifiers blank | +| State or channel spectrum | Its own `colorimetry.spectral_power_distribution` | `spectral_power_distribution` rows with exactly one of `state_id` or `channel_id` | +| Searchable white-range endpoints | Generated `index.cct_range_low_k` and `index.cct_range_high_k` | Produced by `ulc build-index`, never records-sheet columns | + +`baseline_role` is required when the block is present, with at least one state or channel. Roles are `range_low_end`, `range_high_end`, `setting` and `dim_level`. Each listed state needs a unique slug `state_id`, its role and colorimetry containing a nominal CCT bin or a provenanced `measured_cct_k`. Retain its printed label when supplied. A dim-level role requires a percent greater than zero and at most 100; a setting forbids a percent, while an endpoint may carry one. States sharing CCT and dim level must each have a label. The tested baseline can supply either endpoint, so a listed endpoint must not repeat its role. + +Map each channel's unique slug `channel_id` and `kind` (`white` or `color`), plus its published label, nominal CCT, peak wavelength and flux when available. Channels describe emitters rather than operating states. The `color_states` and `emission_channels` sheets carry provenance companions for their measurement columns; dim-level percentages are scalar metadata. Direct emitters use the same provenanced-number shapes and evidence-family policy as the top-level measurements. Attach and hash the source documents for every published measurement. + +Keep each baseline, state and channel spectrum in its own group. On the spectrum sheet, identify that group with (`record_id`, `state_id`, `channel_id`); blank state/channel identifiers select the baseline. Sample grids, metadata and provenance are independent per group. Each state or channel identifier must name an entry emitted for that record. + +After assembling the deep blocks, run the index/validate pipeline above. The builder resolves each state's nominal CCT first, then its measured value; the baseline first uses `configuration.tested_conditions.nominal_cct_at_test`, then its top-level colorimetry. It emits both range fields only when both endpoints resolve. Tunable-white and dim-to-warm records need both endpoints for Standard and CRI Ra plus melanopic DER at both endpoints for Full. An endpoint represented by the baseline reads those metrics from the top-level blocks. Channels and endpoint spectra are enrichment. The Full roadmap's `/color_tuning/states` row represents the endpoint-metric requirement, so inspect both endpoints and the baseline's measurement blocks when that gap appears. See [workbook authoring](../../templates/workbook/README.md#color-state-sheet) and [color-state methodology](../../docs/methodology.md#color-states). + ## Out of scope These guides describe the PIM-to-ULC transformation. They do not cover: diff --git a/mappings/pim/akeneo.md b/mappings/pim/akeneo.md index 5049489..d4c9c47 100644 --- a/mappings/pim/akeneo.md +++ b/mappings/pim/akeneo.md @@ -136,6 +136,8 @@ The ULC emit can be modeled as an Akeneo channel (`ulc_export`) with its own com ## Example transform skeleton (PHP, Akeneo's native stack) +For tunable or color-mixing products, extend this tested-baseline skeleton with the shared [published color-state and channel mapping](README.md#7-published-color-states-and-emitter-channels), then generate the range fields with `ulc build-index`. + ```php // Illustrative pseudocode, not a working implementation. function emitUlcFromAkeneo(Product $product, Variant $variant): ?string { diff --git a/mappings/pim/custom-pim.md b/mappings/pim/custom-pim.md index 5a8c109..739e496 100644 --- a/mappings/pim/custom-pim.md +++ b/mappings/pim/custom-pim.md @@ -196,6 +196,8 @@ Schedule a periodic `ulc validate --expiry` over the published corpus to preview ## Example transform skeleton (Python + SQLAlchemy) +For tunable or color-mixing products, extend this tested-baseline skeleton with the shared [published color-state and channel mapping](README.md#7-published-color-states-and-emitter-channels), then generate the range fields with `ulc build-index`. + ```python # Illustrative pseudocode. def emit_ulc(session: Session): diff --git a/mappings/pim/salsify.md b/mappings/pim/salsify.md index 1027b2a..ad1235b 100644 --- a/mappings/pim/salsify.md +++ b/mappings/pim/salsify.md @@ -148,6 +148,8 @@ Accessory-type classification requires another PIM-to-ULC enum mapping (junction ## Example transform skeleton (Python) +For tunable or color-mixing products, extend this tested-baseline skeleton with the shared [published color-state and channel mapping](README.md#7-published-color-states-and-emitter-channels), then generate the range fields with `ulc build-index`. + ```python # Illustrative pseudocode, not a working implementation. def emit_ulc_from_salsify(product, scenario): diff --git a/mappings/pim/sap.md b/mappings/pim/sap.md index 391d5dd..dd9af31 100644 --- a/mappings/pim/sap.md +++ b/mappings/pim/sap.md @@ -159,6 +159,8 @@ SAP's configurable materials (class-type 300) describe a material with variant c ## Example transform skeleton (ABAP / Python hybrid) +For tunable or color-mixing products, extend this tested-baseline skeleton with the shared [published color-state and channel mapping](README.md#7-published-color-states-and-emitter-channels), then generate the range fields with `ulc build-index`. + In practice, ABAP handles data extraction (CDS views or SAP CAP/RAP) and a Python microservice handles JSON assembly and the ULC CLI shell-out: ```python From f66bbc1b5f44b23a767e23f94379075dbd269a39 Mon Sep 17 00:00:00 2001 From: Foad Shafighi <208281409+foadshafighi@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:42:38 -0400 Subject: [PATCH 10/10] Clarify white operating states and colored-scene scope --- mappings/pim/README.md | 2 +- templates/workbook/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mappings/pim/README.md b/mappings/pim/README.md index 9d2fd95..5cf7d41 100644 --- a/mappings/pim/README.md +++ b/mappings/pim/README.md @@ -118,7 +118,7 @@ Because the index is a projection, re-run `ulc build-index` after any deep-block ### 7. Published color states and emitter channels -For a tunable-white, dim-to-warm or color-mixing luminaire, map the published operating states and emitter channels into `color_tuning`. Keep the tested state in the existing top-level `colorimetry`, `alpha_opic_metrics` and `photometry` blocks. The `states` array contains only the other published states; do not duplicate the tested baseline. A static-white record, or a record with no color-tunability capability, carries no `color_tuning` block. +For a tunable-white, dim-to-warm or color-mixing luminaire, map published white operating states, each with a CCT, into `color_tuning.states`. Colored emitters are channels in `color_tuning.channels`; colored scenes are not represented in 1.18.0. Keep the tested state in the existing top-level `colorimetry`, `alpha_opic_metrics` and `photometry` blocks. The `states` array contains only the other published states; do not duplicate the tested baseline. A static-white record, or a record with no color-tunability capability, carries no `color_tuning` block. | PIM data | Authoritative ULC field | Workbook authoring equivalent | |---|---|---| diff --git a/templates/workbook/README.md b/templates/workbook/README.md index 137c126..84b2db4 100644 --- a/templates/workbook/README.md +++ b/templates/workbook/README.md @@ -178,7 +178,7 @@ release 1.5.0 on. | `excluded_combinations` | SKUs orderable elsewhere but out of scope for this record. | Patterns B and D | | `alpha_opic` | Alpha-opic / melanopic per-photoreceptor efficacy with per-value provenance companions. Every filled `efficacy` requires an authored `efficacy_unit` of `W/lm` or `mW/lm`. | Full enrichment | | `spectral_power_distribution` | One row per wavelength sample for a uniformly spaced spectrum, with provenance for the whole spectrum. | Colorimetry; a Full requirement for white-light records from 1.15.0 | -| `color_states` | Published luminaire states other than the tested baseline, with CCT, dim level, quality metrics, melanopic DER, flux and provenance companions. | Tunable or color-mixing records | +| `color_states` | Published white operating states other than the tested baseline, with CCT, dim level, quality metrics, melanopic DER, flux and provenance companions. | Tunable or color-mixing records | | `emission_channels` | Emitter channels with kind, nominal CCT or peak wavelength, flux and provenance companions. | Color-mixing records | | `tm_30_hue_bins` | The sixteen TM-30 local fidelity values (Rf,h), one row per hue bin 1 to 16, with per-value provenance companions. Needs `tm_30_rf` on `records`. | White-light records; a Full requirement | | `flicker_metrics` | TLA metrics (SVM, Pst_LM, percent flicker) with per-value provenance companions and a metric-specific unit rule. | Full enrichment | @@ -246,7 +246,7 @@ Use `tm_30_hue_bins` for the sixteen local color fidelity values (Rf,h) of a TM- For a tunable-white or dim-to-warm record, keep the tested baseline on `records` and fill `color_tuning_baseline_role` with `range_low_end`, `range_high_end`, `setting` or `dim_level`. Fill `color_tuning_baseline_label` when printed and `color_tuning_baseline_dim_level_percent` for a dim-level baseline; an endpoint may also carry its published percent. A setting cannot carry a percent. The role requires at least one row on `color_states` or `emission_channels`; static-white records and records without `color_tunability` cannot carry these rows. -Use one `color_states` row for each other published state: `state_id` (a unique lowercase slug), `role`, the printed `label` when needed, `dim_level_percent`, and `nominal_cct_k` as a taxonomy bin or `measured_cct_k` as a numeric value. An off-bin rated endpoint uses `measured_cct_k__value_type=rated`. The other value columns are `duv`, `cri_ra`, `cri_r9`, `tm_30_rf`, `tm_30_rg`, `melanopic_der`, `lumens` and `efficacy_lm_per_w`. Each has the six provenance companions; defaults are rated, datasheet_pdf, transcribed. A dim-level state requires a percent greater than zero and at most 100. Do not repeat the baseline or its endpoint role. States sharing CCT and dim level must each have a printed label; ask the manufacturer for a missing name rather than inventing one. Both white endpoints are required for Standard; their CRI Ra and melanopic DER are required for Full. Other settings and dim-level points never gate a grade. +Use one `color_states` row for each other published white operating state, each with a CCT. Colored emitters are channels on `emission_channels`; colored scenes are not represented in 1.18.0. The state columns are `state_id` (a unique lowercase slug), `role`, the printed `label` when needed, `dim_level_percent`, and `nominal_cct_k` as a taxonomy bin or `measured_cct_k` as a numeric value. An off-bin rated endpoint uses `measured_cct_k__value_type=rated`. The other value columns are `duv`, `cri_ra`, `cri_r9`, `tm_30_rf`, `tm_30_rg`, `melanopic_der`, `lumens` and `efficacy_lm_per_w`. Each has the six provenance companions; defaults are rated, datasheet_pdf, transcribed. A dim-level state requires a percent greater than zero and at most 100. Do not repeat the baseline or its endpoint role. States sharing CCT and dim level must each have a printed label; ask the manufacturer for a missing name rather than inventing one. Both white endpoints are required for Standard; their CRI Ra and melanopic DER are required for Full. Other settings and dim-level points never gate a grade. ## Emission-channel sheet and state spectra