# HG changeset patch
# User drewp@bigasterisk.com
# Date 2023-05-31 06:52:37
# Node ID 92d97e17ca317ab25454a45eb8c52931c54b8f14
# Parent 8c82f13a3298cc56e4476986f09b8aff7e1ff576
big effecteval rewrite
diff --git a/light9/effect/effecteval.py b/light9/effect/effecteval.py
--- a/light9/effect/effecteval.py
+++ b/light9/effect/effecteval.py
@@ -2,21 +2,23 @@ import logging
import math
import random
from colorsys import hsv_to_rgb
-from dataclasses import dataclass
-from typing import Dict, Optional, Tuple
+from dataclasses import dataclass, field
+import time
+from typing import Callable, Dict, List, Optional, Tuple, cast
+from light9.effect.effect_function_library import EffectFunctionLibrary
from light9.typedgraph import typedValue
from noise import pnoise1
from PIL import Image
from rdfdb.syncedgraph.syncedgraph import SyncedGraph
-from rdflib import RDF, Literal, Namespace, URIRef
+from rdflib import RDF, RDFS, Literal, Namespace, URIRef
from webcolors import hex_to_rgb, rgb_to_hex
from light9.effect.scale import scale
from light9.effect.settings import BareEffectSettings, DeviceSettings, EffectSettings
-from light9.effect.simple_outputs import SimpleOutputs
-from light9.namespaces import DEV, L9
-from light9.newtypes import (DeviceAttr, DeviceUri, EffectAttr, EffectClass, EffectUri, VTUnion)
+from light9.namespaces import DEV, L9, FUNC
+from light9.newtypes import (DeviceAttr, DeviceUri, EffectAttr,
+ EffectFunction, EffectUri, HexColor, VTUnion)
SKY = Namespace('http://light9.bigasterisk.com/theater/skyline/device/')
@@ -71,92 +73,6 @@ def _8bit(f):
return clamp255(int(f * 255))
-@dataclass
-class EffectEval2:
- graph: SyncedGraph
- uri: EffectUri
-
- effectFunction: Optional[URIRef] = None
- isEffect = False
-
- def __post_init__(self):
- self.graph.addHandler(self._compile)
- self.effectFunction = L9['todo']
-
- def _compile(self):
- if not self.graph.contains((self.uri, RDF.type, L9['Effect'])):
- self.isEffect = False
- return
- self.isEffect = True
-
- self.function = effect_scale
- devs = []
- for s in self.graph.objects(self.uri, L9['setting']):
- d = typedValue(DeviceUri, self.graph, s, L9['device'])
- da = typedValue(DeviceAttr, self.graph, s, L9['deviceAttr'])
- v = typedValue(VTUnion, self.graph, s, L9['value'])
- devs.append((d, da, v))
- self.devs = DeviceSettings(self.graph, devs)
-
- def compute(self, inputs: EffectSettings) -> DeviceSettings:
- if not self.isEffect:
- return DeviceSettings(self.graph, [])
-
- s = 0
- for e, ea, v in inputs.asList():
- if not isinstance(v, float):
- raise TypeError
- if ea == L9['strength']:
- s = v
-
- return effect_scale(s, self.devs)
-
- return self.function(inputs)
-
-
-
-
-@dataclass
-class EffectEval:
- """
- runs one effect's code to turn effect attr settings into output
- device settings. No effect state; suitable for reload().
- """
- graph: SyncedGraph
- effect: EffectClass
- simpleOutputs: SimpleOutputs
-
- def outputFromEffect(self, effectSettings: BareEffectSettings, songTime: float, noteTime: float) -> Tuple[DeviceSettings, 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.
- """
- # todo: what does the next comment line mean?
- # both callers need to apply note overrides
-
- strength = float(effectSettings.s[EffectAttr(L9['strength'])])
- if strength <= 0:
- return DeviceSettings(self.graph, []), {'zero': True}
-
- report = {}
- out: Dict[Tuple[DeviceUri, DeviceAttr], VTUnion] = {}
-
- 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/']):]
- try:
- func = globals()[tail]
- except KeyError:
- report['error'] = 'effect code not found for %s' % self.effect
- else:
- out.update(func(effectSettings, strength, songTime, noteTime))
-
- outList = [(d, a, v) for (d, a), v in out.items()]
- return DeviceSettings(self.graph, outList), report
-
-
def effect_Curtain(effectSettings, strength, songTime, noteTime):
return {(L9['device/lowPattern%s' % n], L9['color']): literalColor(strength, strength, strength) for n in range(301, 308 + 1)}
diff --git a/light9/effect/effecteval2.py b/light9/effect/effecteval2.py
new file mode 100644
--- /dev/null
+++ b/light9/effect/effecteval2.py
@@ -0,0 +1,115 @@
+import inspect
+import logging
+from dataclasses import dataclass, field
+from typing import Callable, Dict, List, Optional, Tuple, cast
+
+from rdfdb.syncedgraph.syncedgraph import SyncedGraph
+from rdflib import RDF, RDFS, Literal, Namespace, URIRef
+
+from light9.effect.effect_function_library import EffectFunctionLibrary
+from light9.effect.settings import (BareEffectSettings, DeviceSettings, EffectSettings)
+from light9.namespaces import DEV, FUNC, L9
+from light9.newtypes import (DeviceAttr, DeviceUri, EffectAttr, EffectFunction, EffectUri, HexColor, VTUnion)
+from light9.typedgraph import typedValue
+from rdflib.term import Node
+
+log = logging.getLogger('effecteval')
+
+@dataclass
+class Config:
+ effectFunction: EffectFunction
+ esettings: EffectSettings
+ devSettings: Optional[DeviceSettings]# the EffectSettings :effectAttr :devSettings item, if there was one
+ func: Callable
+ funcArgs: List[inspect.Parameter]
+
+@dataclass
+class EffectEval2:
+ """Runs one effect code to turn EffectSettings (e.g. strength) into DeviceSettings"""
+ graph: SyncedGraph
+ uri: EffectUri
+ lib: EffectFunctionLibrary
+
+ config:Optional[Config]=None
+
+ def __post_init__(self):
+ self.graph.addHandler(self._compile)
+
+ def _compile(self):
+ self.effectFunction = None
+ if not self.graph.contains((self.uri, RDF.type, L9['Effect'])):
+ return
+
+ try:
+ effectFunction = typedValue(EffectFunction, self.graph, self.uri, L9['effectFunction'])
+ effSets = []
+ devSettings = None
+ for s in self.graph.objects(self.uri, L9['setting']):
+ attr = typedValue(EffectAttr, self.graph, s, L9['effectAttr'])
+ if attr == L9['deviceSettings']:
+ value = typedValue(Node, self.graph, s, L9['value'])
+
+ rows = []
+ for ds in self.graph.objects(value, L9['setting']):
+ d = typedValue(DeviceUri, self.graph, ds, L9['device'])
+ da = typedValue(DeviceAttr, self.graph, ds, L9['deviceAttr'])
+ v = typedValue(VTUnion, self.graph, ds, L9['value'])
+ rows.append((d, da, v))
+ devSettings = DeviceSettings(self.graph, rows)
+ else:
+ value = typedValue(VTUnion, self.graph, s, L9['value'])
+ effSets.append((self.uri, attr, value))
+ esettings = EffectSettings(self.graph, effSets)
+
+ try:
+ effectFunction = typedValue(EffectFunction, self.graph, self.uri, L9['effectFunction'])
+ except ValueError:
+ raise ValueError(f'{self.uri} has no :effectFunction')
+ func = self.lib.getFunc(effectFunction)
+
+ # This should be in EffectFunctionLibrary
+ funcArgs = list(inspect.signature(func).parameters.values())
+
+ self.config = Config(effectFunction, esettings, devSettings, func, funcArgs)
+ except Exception:
+ log.error(f"while compiling {self.uri}")
+ raise
+
+
+ def compute(self, songTime: float, inputs: EffectSettings) -> DeviceSettings:
+ """
+ calls our function using inputs (publishedAttr attrs, e.g. :strength)
+ and effect-level settings including a special attr called :deviceSettings
+ with DeviceSettings as its value
+ """
+ if self.config is None:
+ return DeviceSettings(self.graph, [])
+
+ c = self.config
+ kw = {}
+ for arg in c.funcArgs:
+ if arg.annotation == DeviceSettings:
+ v = c.devSettings
+ elif arg.name == 'songTime':
+ v = songTime
+ else:
+ eaForName = EffectAttr(L9[arg.name])
+ v = self._getEffectAttrValue(eaForName, inputs)
+
+ kw[arg.name] = v
+ log.debug('calling %s with %s', c.func, kw)
+ return c.func(**kw)
+
+ def _getEffectAttrValue(self, attr: EffectAttr, inputs: EffectSettings) -> VTUnion:
+ c=self.config
+ if c is None: raise
+ try:
+ return inputs.getValue(self.uri, attr, defaultToZero=False)
+ except KeyError:
+ pass
+ try:
+ return c.esettings.getValue(self.uri, attr, defaultToZero=False)
+ except KeyError:
+ pass
+ return self.lib.getDefaultValue(c.effectFunction, attr)
+
\ No newline at end of file
diff --git a/light9/effect/effecteval_test.py b/light9/effect/effecteval_test.py
--- a/light9/effect/effecteval_test.py
+++ b/light9/effect/effecteval_test.py
@@ -1,31 +1,121 @@
-from light9.effect.effecteval import EffectEval2
+from typing import List, Tuple
+from light9.effect.effect_function_library import EffectFunctionLibrary
+from light9.effect.effecteval2 import EffectEval2
from light9.effect.settings import DeviceSettings, EffectSettings
from light9.mock_syncedgraph import MockSyncedGraph
from light9.namespaces import DEV, L9
-from light9.newtypes import (DeviceAttr, DeviceUri, EffectAttr, EffectUri, HexColor)
+from light9.newtypes import (DeviceAttr, DeviceUri, EffectAttr, EffectUri, HexColor, VTUnion)
+import pytest
PREFIX = '''
@prefix : .
@prefix dev: .
@prefix effect: .
-
+ @prefix func: .
+ @prefix rdfs: .
'''
-THEATER = PREFIX + '''
- :effect1
+GRAPH = PREFIX + '''
+
+ func:scale
+ a :EffectFunction;
+ rdfs:label "a submaster- scales :deviceSettings";
+ :input
+ [ :effectAttr :strength; :defaultValue 0.0 ],
+ [ :effectAttr :deviceSettings; ] . # e.g. "par2 at color=red; par3 at color=white"
+
+ func:strobe
+ a :EffectFunction;
+ rdfs:label "blink specified devices";
+ :input
+ [ :effectAttr :strength; :defaultValue 0.0 ],
+ [ :effectAttr :period; :defaultValue 0.5 ],
+ [ :effectAttr :onTime; :defaultValue 0.1 ],
+ [ :effectAttr :deviceSettings ] .
+
+ func:image
+ a :EffectFunction;
+ rdfs:label "sample image at x=time";
+ :input
+ [ :effectAttr :strength; :defaultValue 0.0 ],
+ [ :effectAttr :period; :defaultValue 2.0 ],
+ [ :effectAttr :image; :defaultValue "specks.png" ],
+ [ :effectAttr :deviceSettings; rdfs:comment "these might have a :sort key or a :y value" ] .
+
+
+ :effectSub
a :Effect;
- :effectFunction effect:scale;
- :input [ :effectAttr :strength ];
- :setting [ :device dev:light1; :deviceAttr :color; :value "#ff0000" ] .
+ :effectFunction func:scale;
+ :publishAttr :strength;
+ :setting [ :effectAttr :deviceSettings; :value [
+ :setting [ :device dev:light1; :deviceAttr :color; :value "#ff0000" ] ] ].
+
+ :effectDefaultStrobe
+ a :Effect;
+ :effectFunction func:strobe;
+ :publishAttr :strength;
+ :setting [ :effectAttr :deviceSettings; :value [
+ :setting [ :device dev:light1; :deviceAttr :color; :value "#ff0000" ] ] ].
+
+ :effectCustomStrobe
+ a :Effect;
+ :effectFunction func:strobe;
+ :publishAttr :strength;
+ :setting
+ [ :effectAttr :period; :value 3.0],
+ [ :effectAttr :onTime; :value 0.5],
+ [ :effectAttr :deviceSettings; :value [
+ :setting [ :device dev:light1; :deviceAttr :color; :value "#ff0000" ] ] ].
'''
-effect1 = EffectUri(L9['effect1'])
+
+effectSub = EffectUri(L9['effectSub'])
+effectDefaultStrobe = EffectUri(L9['effectDefaultStrobe'])
+effectCustomStrobe = EffectUri(L9['effectCustomStrobe'])
+
+
+def light1At(col: str) -> List[Tuple[DeviceUri, DeviceAttr, VTUnion]]:
+ return [(DeviceUri(DEV['light1']), DeviceAttr(L9['color']), HexColor(col))]
+
+
+@pytest.fixture
+def effectFunctions():
+ g = MockSyncedGraph(GRAPH)
+ return EffectFunctionLibrary(g)
class TestEffectEval:
- def test_scalesColors(self):
- g = MockSyncedGraph(THEATER)
- ee = EffectEval2(g, effect1)
- s = EffectSettings(g, [(effect1, EffectAttr(L9['strength']), 0.5)])
- ds = ee.compute(s)
- assert ds == DeviceSettings(g, [(DeviceUri(DEV['light1']), DeviceAttr(L9['color']), HexColor('#7f0000'))])
+ def test_scalesColors(self, effectFunctions):
+ g = effectFunctions.graph
+ ee = EffectEval2(g, effectSub, effectFunctions)
+ s = EffectSettings(g, [(effectSub, EffectAttr(L9['strength']), 0.5)])
+ ds = ee.compute(songTime=0, inputs=s)
+ assert ds == DeviceSettings(g, light1At('#7f0000'))
+
+ def test_cullsZeroOutputs(self, effectFunctions):
+ g = effectFunctions.graph
+ ee = EffectEval2(g, effectSub, effectFunctions)
+ s = EffectSettings(g, [(effectSub, EffectAttr(L9['strength']), 0.0)])
+ ds = ee.compute(songTime=0, inputs=s)
+ assert ds == DeviceSettings(g, [])
+
+ def test_strobeDefaults(self, effectFunctions):
+ g = effectFunctions.graph
+ ee = EffectEval2(g, effectDefaultStrobe, effectFunctions)
+ s = EffectSettings(g, [(effectDefaultStrobe, EffectAttr(L9['strength']), 1.0)])
+ assert ee.compute(songTime=0, inputs=s) == DeviceSettings(g, light1At('#ff0000'))
+ assert ee.compute(songTime=.25, inputs=s) == DeviceSettings(g, [])
+
+ def strobeMultsStrength(self, effectFunctions):
+ g = effectFunctions.graph
+ ee = EffectEval2(g, effectDefaultStrobe, effectFunctions)
+ s = EffectSettings(g, [(effectDefaultStrobe, EffectAttr(L9['strength']), 0.5)])
+ assert ee.compute(songTime=0, inputs=s) == DeviceSettings(g, light1At('#7f0000'))
+
+ def test_strobeCustom(self, effectFunctions):
+ g = effectFunctions.graph
+ ee = EffectEval2(g, effectCustomStrobe, effectFunctions)
+ s = EffectSettings(g, [(effectCustomStrobe, EffectAttr(L9['strength']), 1.0)])
+ assert ee.compute(songTime=0, inputs=s) == DeviceSettings(g, light1At('#ff0000'))
+ assert ee.compute(songTime=.25, inputs=s) == DeviceSettings(g, light1At('#ff0000'))
+ assert ee.compute(songTime=.6, inputs=s) == DeviceSettings(g, [])
diff --git a/light9/effect/sequencer/eval_faders.py b/light9/effect/sequencer/eval_faders.py
--- a/light9/effect/sequencer/eval_faders.py
+++ b/light9/effect/sequencer/eval_faders.py
@@ -4,12 +4,13 @@ from dataclasses import dataclass
from typing import List, Optional, cast
from prometheus_client import Summary
+from light9.effect.effect_function_library import EffectFunctionLibrary
+from light9.effect.effecteval2 import EffectEval2
from rdfdb import SyncedGraph
from rdflib import URIRef
from rdflib.term import Node
-from light9.effect import effecteval
from light9.effect.settings import DeviceSettings, EffectSettings
from light9.metrics import metrics
from light9.namespaces import L9, RDF
@@ -23,6 +24,7 @@ COMPILE=Summary('compile_graph_fader', '
@dataclass
class Fader:
graph: SyncedGraph
+ lib: EffectFunctionLibrary
uri: URIRef
effect: EffectUri
setEffectAttr: EffectAttr
@@ -30,7 +32,7 @@ class Fader:
value: Optional[float]=None # mutable
def __post_init__(self):
- self.ee = effecteval.EffectEval2(self.graph, self.effect)
+ self.ee = EffectEval2(self.graph, self.effect, self.lib)
class FaderEval:
"""peer to Sequencer, but this one takes the current :Fader settings -> sendToCollector
@@ -38,18 +40,15 @@ class FaderEval:
"""
def __init__(self,
graph: SyncedGraph,
+ lib: EffectFunctionLibrary
):
self.graph = graph
+ self.lib = lib
self.faders: List[Fader] = []
- log.info('fader adds handler')
self.graph.addHandler(self._compile)
self.lastLoopSucceeded = False
- def onCodeChange(self):
- log.debug('seq.onCodeChange')
- self.graph.addHandler(self._compile)
-
@COMPILE.time()
def _compile(self) -> None:
"""rebuild our data from the graph"""
@@ -58,7 +57,7 @@ class FaderEval:
effect = typedValue(EffectUri, self.graph, fader, L9['effect'])
setting = typedValue(Node, self.graph, fader, L9['setting'])
setAttr = typedValue(EffectAttr, self.graph, setting, L9['effectAttr'])
- self.faders.append(Fader(self.graph, cast(URIRef, fader), effect, setAttr))
+ self.faders.append(Fader(self.graph, self.lib, cast(URIRef, fader), effect, setAttr))
# this could go in a second, smaller addHandler call to avoid rebuilding Fader objs constantly
for f in self.faders:
@@ -73,18 +72,7 @@ class FaderEval:
raise TypeError('f.value should be set by now')
effectSettings = EffectSettings(self.graph, [(f.effect, f.setEffectAttr, f.value)])
- if f.value < .001:
- continue
-
- faderEffectOutputs.append(f.ee.compute(effectSettings))
+ ds = f.ee.compute(now, effectSettings)
+ faderEffectOutputs.append(ds)
- # ee = effecteval.EffectEval(self.graph, f.effectClass, self.simpleOutputs)
- # deviceSettings, report = ee.outputFromEffect(
- # effectSettings,
- # songTime=now, # probably wrong
- # noteTime=now, # wrong
- # )
- # log.info(f' 𝅘𝅥𝅮 {uriTail(f.uri)}: {effectSettings=} -> {deviceSettings=}')
- # if deviceSettings:
- # notesSettings.append(deviceSettings)
return DeviceSettings.merge(self.graph, faderEffectOutputs)
diff --git a/light9/effect/sequencer/service.py b/light9/effect/sequencer/service.py
--- a/light9/effect/sequencer/service.py
+++ b/light9/effect/sequencer/service.py
@@ -6,6 +6,7 @@ import asyncio
import json
import logging
import time
+from light9.effect.effect_function_library import EffectFunctionLibrary
from louie import dispatcher
from rdfdb.syncedgraph.syncedgraph import SyncedGraph
@@ -52,9 +53,11 @@ def main():
logging.getLogger('autodepgraphapi').setLevel(logging.INFO)
logging.getLogger('syncedgraph').setLevel(logging.INFO)
logging.getLogger('sse_starlette.sse').setLevel(logging.INFO)
+ logging.getLogger('effecteval').setLevel(logging.INFO)
# seq = Sequencer(graph, send) # per-song timed notes
- faders = FaderEval(graph) # bin/fade's untimed effects
+ lib = EffectFunctionLibrary(graph)
+ faders = FaderEval(graph, lib) # bin/fade's untimed effects
#@metrics('computeAndSend').time() # needs rework with async
async def update(first_run):
diff --git a/light9/effect/settings.py b/light9/effect/settings.py
--- a/light9/effect/settings.py
+++ b/light9/effect/settings.py
@@ -171,8 +171,12 @@ class _Settings:
words = ['(no settings)']
return '<%s %s>' % (self.__class__.__name__, ' '.join(words))
- def getValue(self, dev: EntityType, attr: AttrType):
- return self._compiled.get(dev, {}).get(attr, self._zeroForAttr(attr))
+ def getValue(self, dev: EntityType, attr: AttrType, defaultToZero=True):
+ x = self._compiled.get(dev, {})
+ if defaultToZero:
+ return x.get(attr, self._zeroForAttr(attr))
+ else:
+ return x[attr]
def _vectorKeys(self, deviceAttrFilter=None):
"""stable order of all the dev,attr pairs for this type of settings"""