changeset 2188:9fc653ee7fff

WIP redoing how Note works. The new Note outputs EffectSettings only, and callers have to effect_eval them. Still not sure what SimpleOutputs does.
author drewp@bigasterisk.com
date Sat, 20 May 2023 14:49:15 -0700
parents ccdfdc8183ad
children 504978369ab0
files light9/effect/effecteval.py light9/effect/sequencer/eval_faders.py light9/effect/sequencer/eval_faders_test.py light9/effect/sequencer/note.py light9/effect/sequencer/note_test.py light9/effect/sequencer/sequencer.py light9/effect/settings.py light9/effect/settings_test.py light9/effect/simple_outputs.py light9/newtypes.py
diffstat 10 files changed, 402 insertions(+), 161 deletions(-) [+]
line wrap: on
line diff
--- a/light9/effect/effecteval.py	Fri May 19 21:14:01 2023 -0700
+++ b/light9/effect/effecteval.py	Sat May 20 14:49:15 2023 -0700
@@ -1,15 +1,21 @@
-from rdflib import Literal, URIRef, Namespace
-from light9.namespaces import L9, DEV
-from webcolors import rgb_to_hex, hex_to_rgb
+from dataclasses import dataclass
+import logging
+import math
+import random
 from colorsys import hsv_to_rgb
-import math
+from typing import Any, Dict, Tuple
+from light9.effect.simple_outputs import SimpleOutputs
+from light9.newtypes import DeviceAttr, DeviceUri, EffectAttr, EffectClass, VTUnion
+
 from noise import pnoise1
-import logging
-from light9.effect.settings import DeviceSettings
+from PIL import Image
+from rdflib import Literal, Namespace, URIRef
+from webcolors import hex_to_rgb, rgb_to_hex
+
 from light9.effect.scale import scale
-from typing import Dict, Tuple, Any
-from PIL import Image
-import random
+from light9.effect.settings import BareEffectSettings, DeviceSettings, EffectSettings
+from light9.namespaces import DEV, L9
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
 
 SKY = Namespace('http://light9.bigasterisk.com/theater/skyline/device/')
 
@@ -64,34 +70,32 @@
     return clamp255(int(f * 255))
 
 
+@dataclass
 class EffectEval:
     """
     runs one effect's code to turn effect attr settings into output
     device settings. No state; suitable for reload().
     """
+    graph: SyncedGraph
+    effect: EffectClass
+    simpleOutputs: SimpleOutputs
 
-    def __init__(self, graph, effect, simpleOutputs):
-        self.graph = graph
-        self.effect = effect
-        self.simpleOutputs = simpleOutputs
-
-    def outputFromEffect(self, effectSettings, songTime, noteTime):
+    def outputFromEffect(self, effectSettings: BareEffectSettings, songTime: float, noteTime: float) -> Tuple[EffectSettings, Dict]:
         """
         From effect attr settings, like strength=0.75, to output device
         settings like light1/bright=0.72;light2/bright=0.78. This runs
         the effect code.
         """
         # both callers need to apply note overrides
-        effectSettings = dict(effectSettings)  # we should make everything into nice float and Color objects too
-
-        strength = float(effectSettings[L9['strength']])
+        print(f'outputFromEffect({effectSettings=}, {songTime=}, {noteTime=})')
+        strength = float(effectSettings.s[EffectAttr(L9['strength'])])
         if strength <= 0:
-            return DeviceSettings(self.graph, []), {'zero': True}
+            return EffectSettings(self.graph, []), {'zero': True}
 
         report = {}
-        out: Dict[Tuple[URIRef, URIRef], Any] = {}  # (dev, attr): value
+        out: Dict[Tuple[DeviceUri, DeviceAttr], VTUnion] = {}  # (dev, attr): value
 
-        out.update(self.simpleOutputs.values(self.effect, strength, effectSettings.get(L9['colorScale'], None)))
+        out.update(self.simpleOutputs.values(self.effect, strength, effectSettings.s.get(EffectAttr(L9['colorScale']), None)))
 
         if self.effect.startswith(L9['effect/']):
             tail = 'effect_' + self.effect[len(L9['effect/']):]
@@ -103,7 +107,8 @@
                 out.update(func(effectSettings, strength, songTime, noteTime))
 
         outList = [(d, a, v) for (d, a), v in out.items()]
-        return DeviceSettings(self.graph, outList), report
+        print(f'{outList=}')
+        return EffectSettings(self.graph, outList), report
 
 
 def effect_Curtain(effectSettings, strength, songTime, noteTime):
--- a/light9/effect/sequencer/eval_faders.py	Fri May 19 21:14:01 2023 -0700
+++ b/light9/effect/sequencer/eval_faders.py	Sat May 20 14:49:15 2023 -0700
@@ -1,4 +1,3 @@
-
 import asyncio
 import logging
 import time
@@ -13,9 +12,9 @@
 from light9.effect.simple_outputs import SimpleOutputs
 from light9.metrics import metrics
 from light9.namespaces import L9, RDF
-from light9.newtypes import NoteUri
+from light9.newtypes import NoteUri, UnixTime
 
-log = logging.getLogger('sequencer')
+log = logging.getLogger('seq.fader')
 
 class FaderEval:
     """peer to Sequencer, but this one takes the current :Fader settings -> sendToCollector
@@ -37,8 +36,8 @@
         self.lastLoopSucceeded = False
 
         # self.codeWatcher = CodeWatcher(onChange=self.onCodeChange)
-        log.info('startupdating task')
-        asyncio.create_task(self.startUpdating())
+        # have caller do this
+        #asyncio.create_task(self.startUpdating())
 
     async def startUpdating(self):
         await self.graph.addAsyncHandler(self.update)
@@ -58,25 +57,34 @@
                 return self.compileFader(cast(URIRef, fader))
 
             self.notes.append(compileFader())
-        if self.notes:
-            asyncio.create_task(self.startUpdating())
+        # if self.notes:
+        #     asyncio.create_task(self.startUpdating())
 
 
     @metrics('compile_fader').time()
     def compileFader(self, fader: URIRef) -> Note:
-        return Note(self.graph, NoteUri(cast(NoteUri, fader)), effecteval,
-                self.simpleOutputs, timed=False)
+        return Note(self.graph, cast(NoteUri, fader), 
+                    timed=False)
     
+    def _computeOutput(self) -> DeviceSettings:
+        notesSettings = []
+        now = UnixTime(time.time())
+        for note in self.notes:
+            effectSettings, report = note.outputCurrent()
+
+            ee = effecteval.EffectEval(self.graph, note.effectClass, self.simpleOutputs)
+            deviceSettings, report = ee.outputFromEffect(
+                effectSettings, 
+                songTime=now, # probably wrong
+                noteTime=now, # wrong
+                )
+            notesSettings.append(deviceSettings)
+        return DeviceSettings.merge(self.graph, notesSettings)
+
+
     @metrics('update_call_fader').time()
     async def update(self):
-        settings = []
-        for note in self.notes:
-            effectValue = self.graph.value(note.uri, L9['value'])
-            if effectValue is None:
-                log.info(f'skip note {note}, no :value')
-                continue
-            s, report = note.outputSettings(t=time.time(), strength=float(effectValue))
-            settings.append(s)
-        devSettings = DeviceSettings.fromList(self.graph, settings)
+        log.info(f'update {len(self.notes)=}')
+        devSettings = self._computeOutput()
         with metrics('update_s3_send_fader').time():  # our measurement
             sendSecs = await self.sendToCollector(devSettings)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/effect/sequencer/eval_faders_test.py	Sat May 20 14:49:15 2023 -0700
@@ -0,0 +1,61 @@
+from unittest import mock
+from light9.effect.sequencer.eval_faders import FaderEval
+from light9.effect.settings import DeviceSettings
+from light9.mock_syncedgraph import MockSyncedGraph
+from light9.namespaces import L9
+
+
+PREFIXES = '''
+@prefix : <http://light9.bigasterisk.com/> .
+@prefix effect: <http://light9.bigasterisk.com/effect/> .
+@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
+@prefix show: <http://light9.bigasterisk.com/show/dance2023/> .
+@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
+@prefix dev: <http://light9.bigasterisk.com/theater/test/device/> .
+@prefix dmxA: <http://light9.bigasterisk.com/output/dmxA/> .
+'''
+
+NOTE_GRAPH = PREFIXES + '''
+            :brightness         
+                a :DeviceAttr; 
+                rdfs:label "brightness"; 
+                :dataType :scalar .
+
+            :strength
+                a :EffectAttr;
+                rdfs:label "strength" .
+
+            :SimpleDimmer 
+                a :DeviceClass; 
+                rdfs:label "SimpleDimmer";
+                :deviceAttr :brightness;
+                :attr [ :outputAttr :level; :dmxOffset 0 ] .
+                
+            :light1  
+                a :SimpleDimmer;
+                :dmxUniverse dmxA:;
+                :dmxBase 178 .
+
+            effect:effect1 
+                a :EffectClass;
+                :setting effect:effect1_set1 .
+            effect:effect1_set1 
+                :device :light1; 
+                :deviceAttr :brightness; 
+                :scaledValue 0.5 .
+            :fade1 
+                a :Fader; 
+                :effectClass effect:effect1; 
+                :effectAttr :strength; 
+                :value 0.6 .
+
+        '''
+
+class TestFaderEval:
+    def test_faderValueScalesEffectSettings(self):
+        g = MockSyncedGraph(NOTE_GRAPH)
+        sender = mock.MagicMock()
+        
+        f = FaderEval(g, sender)
+        devSettings = f._computeOutput()
+        assert devSettings == DeviceSettings(g, [(L9['light1'], L9['brightness'], 0.3)])
\ No newline at end of file
--- a/light9/effect/sequencer/note.py	Fri May 19 21:14:01 2023 -0700
+++ b/light9/effect/sequencer/note.py	Sat May 20 14:49:15 2023 -0700
@@ -1,14 +1,18 @@
 import bisect
+from dataclasses import dataclass
 import logging
 import time
 from decimal import Decimal
 from typing import Any, Dict, List, Optional, Tuple, Union, cast
+from light9.effect.effecteval import EffectEval
+from light9.effect.settings import BareEffectSettings, DeviceSettings, EffectSettings
+from light9.effect.simple_outputs import SimpleOutputs
 
 from rdfdb.syncedgraph.syncedgraph import SyncedGraph
 from rdflib import Literal, URIRef
 
 from light9.namespaces import L9
-from light9.newtypes import Curve, DeviceAttr, DeviceUri, NoteUri, typedValue
+from light9.newtypes import Curve, DeviceAttr, EffectAttr, EffectClass, NoteUri, VTUnion, typedValue
 
 log = logging.getLogger('sequencer')
 
@@ -20,32 +24,64 @@
     return ret
 
 
+def prettyFormat(x: Union[float, str]):
+    if isinstance(x, float):
+        return round(x, 4)
+    return x
+
+
+@dataclass
 class Note:
+    """A Note outputs EffectAttr settings.
+    
+    Sample graph:
+     :note1 a :Note; :curve :n1c1; :effectClass effect:allcolor;
 
-    def __init__(self, graph: SyncedGraph, uri: NoteUri, effectevalModule, simpleOutputs, timed=True):
-        g = self.graph = graph
-        self.uri = uri
-        self.timed = timed
-        self.effectEval = effectevalModule.EffectEval(graph, g.value(uri, L9['effectClass']), simpleOutputs)
-        self.baseEffectSettings: Dict[URIRef, Any] = {}  # {effectAttr: value}
-        for s in g.objects(uri, L9['setting']):
-            settingValues = dict(g.predicate_objects(s))
-            ea = cast(URIRef, settingValues[L9['effectAttr']])
-            self.baseEffectSettings[ea] = pyType(settingValues[L9['value']])
+    It can animate the EffectAttr settings over time, in two ways:
+     * a `timed note` has an envelope curve that animates 
+       the :strength EffectAttr over time
+     * an `untimed note` has no curve, a fixed strength, but 
+       still passes the wall clock time to its effect so the 
+       effect can include animation. A `Fader` is an untimed note.
+     
+    This obj is immutable, I think, but the graph can change, 
+    which can affect the output. However, I think this doesn't 
+    do its own rebuilds, and it's up to the caller to addHandler 
+    around the creation of Note objects.
+    """
+    graph: SyncedGraph
+    uri: NoteUri
+    # simpleOutputs: SimpleOutputs
+    timed: bool = True
 
-        if timed:
+    def __post_init__(self):
+        ec = self.graph.value(self.uri, L9['effectClass'])
+        if ec is None:
+            raise ValueError(f'note {self.uri} has no :effectClass')
+        self.effectClass = EffectClass(ec)
 
-            def floatVal(s, p):
-                return typedValue(float, g, s, p)
+        self.baseEffectSettings = self.getBaseEffectSettings()
 
-            originTime = floatVal(uri, L9['originTime'])
+        if self.timed:
+            originTime = typedValue(float, self.graph, self.uri, L9['originTime'])
             self.points: List[Tuple[float, float]] = []
-            for curve in g.objects(uri, L9['curve']):
+            for curve in self.graph.objects(self.uri, L9['curve']):
                 self.points.extend(self.getCurvePoints(cast(Curve, curve), L9['strength'], originTime))
             self.points.sort()
         else:
             self.points = []
 
+    def getBaseEffectSettings(self) -> BareEffectSettings:
+        """i think these are settings that are fixed over time, 
+        e.g. that you set in the note's body in the timeline editor
+        """
+        out: Dict[EffectAttr, VTUnion] = {}
+        for s in self.graph.objects(self.uri, L9['setting']):
+            settingValues = dict(self.graph.predicate_objects(s))
+            ea = cast(EffectAttr, settingValues[L9['effectAttr']])
+            out[ea] = pyType(settingValues[L9['value']])
+        return BareEffectSettings(s=out)
+
     def getCurvePoints(self, curve: Curve, attr, originTime: float) -> List[Tuple[float, float]]:
         points = []
         po = list(self.graph.predicate_objects(curve))
@@ -56,7 +92,7 @@
             t = cast(Literal, po2[L9['time']]).toPython()
             if not isinstance(t, float):
                 raise TypeError
-            
+
             v = cast(Literal, po2[L9['value']]).toPython()
             if not isinstance(v, float):
                 raise TypeError
@@ -81,34 +117,37 @@
         y = p1[1] + (p2[1] - p1[1]) * frac
         return y
 
-    def outputSettings(self, t: float, strength: Optional[float] = None) -> Tuple[List[Tuple[DeviceUri, DeviceAttr, float]], Dict]:
-        """
-        list of (device, attr, value), and a report for web
-        """
+    def outputCurrent(self):      
+        st = typedValue(float, self.graph, self.uri, L9['value'])
+        return self._outputSettings(t=None, strength=st)
+
+    def _outputSettings(
+            self,
+            t: float | None,
+            strength: Optional[float] = None  #
+    ) -> Tuple[BareEffectSettings, Dict]:
+        
         if t is None:
             if self.timed:
                 raise TypeError()
             t = time.time()  # so live effects will move
-        report = {
+        report:Dict[str,Any] = {
             'note': str(self.uri),
-            'effectClass': self.effectEval.effect,
+            'effectClass': str(self.effectClass),
         }
 
-        strengthAttr = cast(DeviceAttr, L9['strength'])
-
-        effectSettings: Dict[DeviceAttr, Union[float, str]] = dict((DeviceAttr(da), v) for da, v in self.baseEffectSettings.items())
-        effectSettings[strengthAttr] = self.evalCurve(t) if strength is None else strength
+        s = self.evalCurve(t) if strength is None else strength
+        out = self.baseEffectSettings.withStrength(s)
+        report['effectSettings'] = dict((str(k), prettyFormat(v)) for k, v in sorted(out.s.items()))
+        report['nonZero'] = s > 0
 
-        def prettyFormat(x: Union[float, str]):
-            if isinstance(x, float):
-                return round(x, 4)
-            return x
+        return out, report
 
-        report['effectSettings'] = dict((str(k), prettyFormat(v)) for k, v in sorted(effectSettings.items()))
-        report['nonZero'] = cast(float, effectSettings[strengthAttr]) > 0
+        # old api had this
+
         startTime = self.points[0][0] if self.timed else 0
         out, evalReport = self.effectEval.outputFromEffect(
-            list(effectSettings.items()),
+            effectSettings,
             songTime=t,
             # note: not using origin here since it's going away
             noteTime=t - startTime)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/light9/effect/sequencer/note_test.py	Sat May 20 14:49:15 2023 -0700
@@ -0,0 +1,79 @@
+import pytest
+
+from light9.effect.sequencer import Note
+from light9.effect.settings import BareEffectSettings
+from light9.mock_syncedgraph import MockSyncedGraph
+from light9.namespaces import L9
+from light9.newtypes import EffectAttr, NoteUri
+
+PREFIXES = '''
+@prefix : <http://light9.bigasterisk.com/> .
+@prefix effect: <http://light9.bigasterisk.com/effect/> .
+@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
+@prefix show: <http://light9.bigasterisk.com/show/dance2023/> .
+@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
+@prefix dev: <http://light9.bigasterisk.com/theater/test/device/> .
+@prefix dmxA: <http://light9.bigasterisk.com/output/dmxA/> .
+'''
+
+FADER_GRAPH = PREFIXES + '''
+    :fade1 
+        a :Fader; 
+        :effectClass effect:effect1; 
+        :effectAttr :strength; 
+        :value 0.6 .
+'''
+
+
+class TestUntimedFaderNote:
+
+    def test_returnsEffectSettings(self):
+        g = MockSyncedGraph(FADER_GRAPH)
+        n = Note(g, NoteUri(L9['fade1']), timed=False)
+        out, report = n.outputCurrent()
+        assert report['effectSettings'] == {'http://light9.bigasterisk.com/strength': 0.6}
+        assert out == BareEffectSettings(s={EffectAttr(L9['strength']): 0.6})
+
+
+NOTE_GRAPH = PREFIXES + '''
+            :brightness         
+                a :DeviceAttr; 
+                rdfs:label "brightness"; 
+                :dataType :scalar .
+
+            :strength
+                a :EffectAttr;
+                rdfs:label "strength" .
+
+            :SimpleDimmer 
+                a :DeviceClass; 
+                rdfs:label "SimpleDimmer";
+                :deviceAttr :brightness;
+                :attr [ :outputAttr :level; :dmxOffset 0 ] .
+                
+            dev:light1  
+                a :SimpleDimmer;
+                :dmxUniverse dmxA:;
+                :dmxBase 178 .
+
+            effect:effect1 
+                a :EffectClass;
+                :setting effect:effect1_set1 .
+            effect:effect1_set1 
+                :device dev:light1; 
+                :deviceAttr :brightness; 
+                :scaledValue 0.5 .
+            :fade1 
+                a :Fader; 
+                :effectClass effect:effect1; 
+                :effectAttr :strength; 
+                :value 0.6 .
+
+        '''
+
+
+class TestTimedNote:
+
+    @pytest.mark.skip()
+    def test_scalesStrengthWithCurve(self):
+        pass
--- a/light9/effect/sequencer/sequencer.py	Fri May 19 21:14:01 2023 -0700
+++ b/light9/effect/sequencer/sequencer.py	Sat May 20 14:49:15 2023 -0700
@@ -100,8 +100,7 @@
         self.notes[song] = []
         for note in self.graph.objects(song, L9['note']):
             try:
-                n = Note(self.graph, NoteUri(cast(NoteUri, note)), effecteval,
-                         self.simpleOutputs)
+                n = Note(self.graph, NoteUri(cast(NoteUri, note)))
             except Exception:
                 log.warn(f"failed to build Note {note} - skipping")
                 anyErrors = True
--- a/light9/effect/settings.py	Fri May 19 21:14:01 2023 -0700
+++ b/light9/effect/settings.py	Sat May 20 14:49:15 2023 -0700
@@ -2,19 +2,31 @@
 Data structure and convertors for a table of (device,attr,value)
 rows. These might be effect attrs ('strength'), device attrs ('rx'),
 or output attrs (dmx channel).
+
+BareSettings means (attr,value), no device.
 """
+from __future__ import annotations
+
 import decimal
+import logging
+from dataclasses import dataclass
+from typing import Any, Dict, Iterable, List, Sequence, Set, Tuple, Union, cast
+from light9.localsyncedgraph import LocalSyncedGraph
+
 import numpy
-from rdflib import URIRef, Literal
-from light9.namespaces import RDF, L9
-import logging
+from rdflib import Literal, URIRef
+
+from light9.collector.device import resolve
+from light9.namespaces import L9, RDF
+from light9.newtypes import DeviceAttr, DeviceUri, EffectAttr, HexColor, VTUnion
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+
 log = logging.getLogger('settings')
-from light9.collector.device import resolve
-from typing import Sequence, Dict, Union, List
 
 
 def parseHex(h):
-    if h[0] != '#': raise ValueError(h)
+    if h[0] != '#':
+        raise ValueError(h)
     return [int(h[i:i + 2], 16) for i in (1, 3, 5)]
 
 
@@ -22,10 +34,10 @@
     return [x / 255 for x in parseHex(h)]
 
 
-def toHex(rgbFloat: Sequence[float]) -> str:
+def toHex(rgbFloat: Sequence[float]) -> HexColor:
     assert len(rgbFloat) == 3
     scaled = (max(0, min(255, int(v * 255))) for v in rgbFloat)
-    return '#%02x%02x%02x' % tuple(scaled)  # type: ignore
+    return HexColor('#%02x%02x%02x' % tuple(scaled))
 
 
 def getVal(graph, subj):
@@ -36,73 +48,64 @@
     return ret
 
 
+GraphType = SyncedGraph | LocalSyncedGraph
+
+
 class _Settings:
     """
+    Generic for DeviceUri/DeviceAttr/VTUnion or EffectClass/EffectAttr/VTUnion
+
     default values are 0 or '#000000'. Internal rep must not store zeros or some
     comparisons will break.
     """
+    EntityType = DeviceUri
+    AttrType = DeviceAttr
 
-    def __init__(self, graph, settingsList):
+    def __init__(self, graph: GraphType, settingsList: List[Tuple[Any, Any, VTUnion]]):
         self.graph = graph  # for looking up all possible attrs
-        self._compiled: Dict[URIRef, Dict[URIRef, Union[float, str]]] = {
-        }  # dev: { attr: val }; val is number or colorhex
+        self._compiled: Dict[self.__class__.EntityType, Dict[self.__class__.AttrType, VTUnion]] = {}
         for row in settingsList:
             self._compiled.setdefault(row[0], {})[row[1]] = row[2]
         # self._compiled may not be final yet- see _fromCompiled
         self._delZeros()
 
     @classmethod
-    def _fromCompiled(cls, graph, compiled):
+    def _fromCompiled(cls, graph: GraphType, compiled: Dict[EntityType, Dict[AttrType, VTUnion]]):
         obj = cls(graph, [])
         obj._compiled = compiled
         obj._delZeros()
         return obj
 
     @classmethod
-    def fromResource(cls, graph, subj):
-        settingsList = []
-        with graph.currentState() as g:
-            for s in g.objects(subj, L9['setting']):
-                d = g.value(s, L9['device'])
-                da = g.value(s, L9['deviceAttr'])
-                v = getVal(g, s)
-                settingsList.append((d, da, v))
-        return cls(graph, settingsList)
-
-    @classmethod
-    def fromVector(cls, graph, vector, deviceAttrFilter=None):
-        compiled: Dict[URIRef, Dict[URIRef, Union[float, str]]] = {}
-        i = 0
-        for (d, a) in cls(graph, [])._vectorKeys(deviceAttrFilter):
-            if a == L9['color']:
-                v = toHex(vector[i:i + 3])
-                i += 3
-            else:
-                v = vector[i]
-                i += 1
-            compiled.setdefault(d, {})[a] = v
-        return cls._fromCompiled(graph, compiled)
-
-    @classmethod
-    def fromList(cls, graph, others):
+    def fromList(cls, graph: GraphType, others: List[_Settings]):
         """note that others may have multiple values for an attr"""
-        out = cls(graph, [])
+        self = cls(graph, [])
         for s in others:
-            if not isinstance(s, cls):
-                raise TypeError(s)
+            # if not isinstance(s, cls):
+            #     raise TypeError(s)
             for row in s.asList():  # could work straight from s._compiled
                 if row[0] is None:
                     raise TypeError('bad row %r' % (row,))
                 dev, devAttr, value = row
-                devDict = out._compiled.setdefault(dev, {})
+                devDict = self._compiled.setdefault(dev, {})
                 if devAttr in devDict:
-                    value = resolve(dev, devAttr, [devDict[devAttr], value])
+                    existingVal: VTUnion = devDict[devAttr]
+                    raise NotImplementedError('fixme: dev is to be a deviceclass (but it is currently unused)')
+                    value = resolve(dev, devAttr, [existingVal, value])
                 devDict[devAttr] = value
-        out._delZeros()
-        return out
+        self._delZeros()
+        return self
 
     @classmethod
-    def fromBlend(cls, graph, others):
+    def _mult(cls, weight, row, dd) -> VTUnion:
+        if isinstance(row[2], str):
+            prev = parseHexNorm(dd.get(row[1], '#000000'))
+            return toHex(prev + weight * numpy.array(parseHexNorm(row[2])))
+        else:
+            return dd.get(row[1], 0) + weight * row[2]
+
+    @classmethod
+    def fromBlend(cls, graph: GraphType, others: List[Tuple[float, _Settings]]):
         """others is a list of (weight, Settings) pairs"""
         out = cls(graph, [])
         for weight, s in others:
@@ -113,19 +116,14 @@
                     raise TypeError('bad row %r' % (row,))
                 dd = out._compiled.setdefault(row[0], {})
 
-                if isinstance(row[2], str):
-                    prev = parseHexNorm(dd.get(row[1], '#000000'))
-                    newVal = toHex(prev +
-                                   weight * numpy.array(parseHexNorm(row[2])))
-                else:
-                    newVal = dd.get(row[1], 0) + weight * row[2]
+                newVal = cls._mult(weight, row, dd)
                 dd[row[1]] = newVal
         out._delZeros()
         return out
 
-    def _zeroForAttr(self, attr):
+    def _zeroForAttr(self, attr: AttrType) -> VTUnion:
         if attr == L9['color']:
-            return '#000000'
+            return HexColor('#000000')
         return 0.0
 
     def _delZeros(self):
@@ -137,15 +135,12 @@
                 del self._compiled[dev]
 
     def __hash__(self):
-        itemed = tuple([(d, tuple([(a, v)
-                                   for a, v in sorted(av.items())]))
-                        for d, av in sorted(self._compiled.items())])
+        itemed = tuple([(d, tuple([(a, v) for a, v in sorted(av.items())])) for d, av in sorted(self._compiled.items())])
         return hash(itemed)
 
     def __eq__(self, other):
         if not issubclass(other.__class__, self.__class__):
-            raise TypeError("can't compare %r to %r" %
-                            (self.__class__, other.__class__))
+            raise TypeError("can't compare %r to %r" % (self.__class__, other.__class__))
         return self._compiled == other._compiled
 
     def __ne__(self, other):
@@ -160,24 +155,24 @@
         def accum():
             for dev, av in self._compiled.items():
                 for attr, val in sorted(av.items()):
-                    words.append(
-                        '%s.%s=%s' %
-                        (dev.rsplit('/')[-1], attr.rsplit('/')[-1], val))
+                    words.append('%s.%s=%s' % (dev.rsplit('/')[-1], attr.rsplit('/')[-1], val))
                     if len(words) > 5:
                         words.append('...')
                         return
 
         accum()
+        if not words:
+            words = ['(no settings)']
         return '<%s %s>' % (self.__class__.__name__, ' '.join(words))
 
-    def getValue(self, dev, attr):
+    def getValue(self, dev: EntityType, attr: AttrType):
         return self._compiled.get(dev, {}).get(attr, self._zeroForAttr(attr))
 
     def _vectorKeys(self, deviceAttrFilter=None):
         """stable order of all the dev,attr pairs for this type of settings"""
         raise NotImplementedError
 
-    def asList(self):
+    def asList(self) -> List[Tuple[EntityType, AttrType, VTUnion]]:
         """old style list of (dev, attr, val) tuples"""
         out = []
         for dev, av in self._compiled.items():
@@ -185,26 +180,27 @@
                 out.append((dev, attr, val))
         return out
 
-    def devices(self):
+    def devices(self) -> List[EntityType]:
         return list(self._compiled.keys())
 
-    def toVector(self, deviceAttrFilter=None):
+    def toVector(self, deviceAttrFilter=None) -> List[float]:
         out: List[float] = []
         for dev, attr in self._vectorKeys(deviceAttrFilter):
             v = self.getValue(dev, attr)
             if attr == L9['color']:
                 out.extend(parseHexNorm(v))
             else:
+                if not isinstance(v, float):
+                    raise TypeError(f'{attr=} value was {v=}')
                 out.append(v)
         return out
 
-    def byDevice(self):
+    def byDevice(self) -> Iterable[Tuple[EntityType, _Settings]]:
         for dev, av in self._compiled.items():
             yield dev, self.__class__._fromCompiled(self.graph, {dev: av})
 
-    def ofDevice(self, dev):
-        return self.__class__._fromCompiled(self.graph,
-                                            {dev: self._compiled.get(dev, {})})
+    def ofDevice(self, dev: EntityType) -> _Settings:
+        return self.__class__._fromCompiled(self.graph, {dev: self._compiled.get(dev, {})})
 
     def distanceTo(self, other):
         diff = numpy.array(self.toVector()) - other.toVector()
@@ -212,7 +208,7 @@
         log.info('distanceTo %r - %r = %g', self, other, d)
         return d
 
-    def statements(self, subj, ctx, settingRoot, settingsSubgraphCache):
+    def statements(self, subj: EntityType, ctx: URIRef, settingRoot: URIRef, settingsSubgraphCache: Set):
         """
         settingRoot can be shared across images (or even wider if you want)
         """
@@ -227,8 +223,7 @@
                 continue
 
             scaledAttributeTypes = [L9['color'], L9['brightness'], L9['uv']]
-            settingType = L9[
-                'scaledValue'] if attr in scaledAttributeTypes else L9['value']
+            settingType = L9['scaledValue'] if attr in scaledAttributeTypes else L9['value']
             if not isinstance(val, URIRef):
                 val = Literal(val)
             add.extend([
@@ -242,6 +237,8 @@
 
 
 class DeviceSettings(_Settings):
+    EntityType = DeviceUri
+    AttrType = DeviceAttr
 
     def _vectorKeys(self, deviceAttrFilter=None):
         with self.graph.currentState() as g:
@@ -258,3 +255,47 @@
                         continue
                     keys.append(key)
         return keys
+
+    @classmethod
+    def fromResource(cls, graph: GraphType, subj: EntityType):
+        settingsList = []
+        with graph.currentState() as g:
+            for s in g.objects(subj, L9['setting']):
+                d = g.value(s, L9['device'])
+                da = g.value(s, L9['deviceAttr'])
+                v = getVal(g, s)
+                settingsList.append((d, da, v))
+        return cls(graph, settingsList)
+
+    @classmethod
+    def fromVector(cls, graph, vector, deviceAttrFilter=None):
+        compiled: Dict[DeviceSettings.EntityType, Dict[DeviceSettings.AttrType, VTUnion]] = {}
+        i = 0
+        for (d, a) in cls(graph, [])._vectorKeys(deviceAttrFilter):
+            if a == L9['color']:
+                v = toHex(vector[i:i + 3])
+                i += 3
+            else:
+                v = vector[i]
+                i += 1
+            compiled.setdefault(d, {})[a] = v
+        return cls._fromCompiled(graph, compiled)
+
+    @classmethod
+    def merge(cls, graph: SyncedGraph, others: List[DeviceSettings]) -> DeviceSettings:
+        return cls.fromList(graph, cast(List[_Settings], others))
+
+
+@dataclass
+class BareEffectSettings:
+    # settings not specific to any effect
+    s: Dict[EffectAttr, VTUnion]
+
+    def withStrength(self, strength: float) -> BareEffectSettings:
+        out = self.s.copy()
+        out[EffectAttr(L9['strength'])] = strength
+        return BareEffectSettings(s=out)
+
+
+class EffectSettings(_Settings):
+    pass
--- a/light9/effect/settings_test.py	Fri May 19 21:14:01 2023 -0700
+++ b/light9/effect/settings_test.py	Sat May 20 14:49:15 2023 -0700
@@ -1,4 +1,6 @@
+from typing import cast
 import unittest
+from light9.newtypes import DeviceAttr, DeviceUri, HexColor, VTUnion
 from rdflib import Literal
 from rdfdb.patch import Patch
 from light9.localsyncedgraph import LocalSyncedGraph
@@ -52,7 +54,7 @@
                 (L9['foo_set1'], L9['deviceAttr'], L9['speed'], ctx),
                 (L9['foo_set1'], L9['scaledValue'], Literal(0.2), ctx),
             ]))
-        s = DeviceSettings.fromResource(self.graph, L9['foo'])
+        s = DeviceSettings.fromResource(self.graph, DeviceUri(L9['foo']))
 
         self.assertEqual(
             DeviceSettings(self.graph, [
@@ -62,8 +64,8 @@
 
     def testToVector(self):
         v = DeviceSettings(self.graph, [
-            (DEV['aura1'], L9['rx'], 0.5),
-            (DEV['aura1'], L9['color'], '#00ff00'),
+            (DeviceUri(DEV['aura1']), DeviceAttr(L9['rx']), 0.5),
+            (DeviceUri(DEV['aura1']), DeviceAttr(L9['color']), HexColor('#00ff00')),
         ]).toVector()
         self.assertEqual([
             0, 1, 0, 0.5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
@@ -78,14 +80,14 @@
 
         self.assertEqual(
             DeviceSettings(self.graph, [
-                (DEV['aura1'], L9['rx'], 0.5),
-                (DEV['aura1'], L9['color'], '#00ff00'),
+                (DeviceUri(DEV['aura1']), DeviceAttr(L9['rx']), 0.5),
+                (DeviceUri(DEV['aura1']), DeviceAttr(L9['color']), HexColor('#00ff00')),
             ]), s)
 
     def testAsList(self):
         sets = [
-            (L9['light1'], L9['attr2'], 0.3),
-            (L9['light1'], L9['attr1'], 0.5),
+            (DeviceUri(L9['light1']), DeviceAttr(L9['attr2']), cast(VTUnion,0.3)),
+            (DeviceUri(L9['light1']), DeviceAttr(L9['attr1']), 0.5),
         ]
         self.assertCountEqual(sets, DeviceSettings(self.graph, sets).asList())
 
@@ -101,7 +103,7 @@
         s = DeviceSettings(self.graph, [
             (DEV['aura2'], L9['rx'], 0.1),
         ])
-        stmts = s.statements(L9['foo'], L9['ctx1'], L9['s_'], set())
+        stmts = s.statements(DeviceUri(L9['foo']), L9['ctx1'], L9['s_'], set())
         self.maxDiff = None
         setting = sorted(stmts)[-1][0]
         self.assertCountEqual([
@@ -157,8 +159,8 @@
 
     def testMixColors(self):
         self.assertEqual(
-            DeviceSettings(self.graph, [(L1, ZOOM, '#503000')]),
+            DeviceSettings(self.graph, [(L1, ZOOM, HexColor('#503000'))]),
             DeviceSettings.fromBlend(self.graph, [
-                (.25, DeviceSettings(self.graph, [(L1, ZOOM, '#800000')])),
-                (.5, DeviceSettings(self.graph, [(L1, ZOOM, '#606000')])),
+                (.25, DeviceSettings(self.graph, [(L1, ZOOM, HexColor('#800000'))])),
+                (.5, DeviceSettings(self.graph, [(L1, ZOOM, HexColor('#606000'))])),
             ]))
--- a/light9/effect/simple_outputs.py	Fri May 19 21:14:01 2023 -0700
+++ b/light9/effect/simple_outputs.py	Sat May 20 14:49:15 2023 -0700
@@ -21,6 +21,9 @@
 
     def updateEffectsFromGraph(self):
         for effect in self.graph.subjects(RDF.type, L9['Effect']):
+            raise TypeError('change graph from Effect to EffectClass')
+
+        for effect in self.graph.subjects(RDF.type, L9['EffectClass']):
             settings = []
             for setting in self.graph.objects(effect, L9['setting']):
                 settingValues = dict(self.graph.predicate_objects(setting))
--- a/light9/newtypes.py	Fri May 19 21:14:01 2023 -0700
+++ b/light9/newtypes.py	Sat May 20 14:49:15 2023 -0700
@@ -1,3 +1,4 @@
+import decimal
 from typing import Tuple, NewType, Type, TypeVar, Union, cast
 from rdflib import URIRef
 from rdflib.term import Node
@@ -11,6 +12,8 @@
 DmxIndex = NewType('DmxIndex', int)  # 1..512
 DmxMessageIndex = NewType('DmxMessageIndex', int)  # 0..511
 DeviceAttr = NewType('DeviceAttr', URIRef)  # e.g. :rx
+EffectClass = NewType('EffectClass', URIRef) # e.g. effect:chase
+EffectAttr = NewType('EffectAttr', URIRef)  # e.g. :chaseSpeed
 NoteUri = NewType('NoteUri', URIRef)
 OutputAttr = NewType('OutputAttr', URIRef)  # e.g. :xFine
 OutputValue = NewType('OutputValue', int)  # byte in dmx message
@@ -45,5 +48,6 @@
     if obj is None:
         raise ValueError()
     conv = obj if _isSubclass2(objType, Node) else obj.toPython()
-    # may need to turn Decimal to float here
+    if objType is float and isinstance(conv, decimal.Decimal):
+        conv = float(conv)
     return cast(objType, conv)
\ No newline at end of file