diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 9aee39a5..d2c42eea 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -10,6 +10,7 @@ on: env: TWINE_PASSWORD: ${{secrets.TWINE_PASSWORD}} GITHUB_API_TOKEN: ${{secrets.API_TOKEN}} + CODECOV_TOKEN: ${{secrets.CODECOV_TOKEN}} jobs: build: @@ -60,6 +61,10 @@ jobs: PY_TEST_EXTRA_ARGS: --cov-report=xml - uses: codecov/codecov-action@v3.1.5 + with: + fail_ci_if_error: true + verbose: true + token: ${{secrets.CODECOV_TOKEN}} - run: mk pypi-upload-ci env: @@ -72,7 +77,7 @@ jobs: - run: | mk python-release owner=vkottler \ - repo=runtimepy version=4.6.1 + repo=runtimepy version=5.0.0 if: | matrix.python-version == '3.11' && matrix.system == 'ubuntu-latest' diff --git a/README.md b/README.md index ce365641..19bae669 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ ===================================== generator=datazen version=3.1.4 - hash=4418deec8fe739bca86f22f58d2f3e1f + hash=895daf8e1a0633698eac373d7017dbde ===================================== --> -# runtimepy ([4.6.1](https://pypi.org/project/runtimepy/)) +# runtimepy ([5.0.0](https://pypi.org/project/runtimepy/)) [![python](https://img.shields.io/pypi/pyversions/runtimepy.svg)](https://pypi.org/project/runtimepy/) ![Build Status](https://github.com/vkottler/runtimepy/workflows/Python%20Package/badge.svg) diff --git a/config b/config index 91a822ff..be94f0ea 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 91a822ff4c9afcad9e62c0352975fac383ef0478 +Subproject commit be94f0eaf88b995471bc16a818ab0ba75c357454 diff --git a/local/configs/package.yaml b/local/configs/package.yaml index 846c5eae..43927984 100644 --- a/local/configs/package.yaml +++ b/local/configs/package.yaml @@ -5,7 +5,7 @@ description: A framework for implementing Python services. entry: {{entry}} requirements: - - vcorelib>=3.2.6 + - vcorelib>=3.2.8 - svgen>=0.6.7 - websockets - psutil diff --git a/local/variables/package.yaml b/local/variables/package.yaml index 8ee52d88..a1c4890c 100644 --- a/local/variables/package.yaml +++ b/local/variables/package.yaml @@ -1,5 +1,5 @@ --- -major: 4 -minor: 6 -patch: 1 +major: 5 +minor: 0 +patch: 0 entry: runtimepy diff --git a/pyproject.toml b/pyproject.toml index 1f4aac71..95f6c410 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta:__legacy__" [project] name = "runtimepy" -version = "4.6.1" +version = "5.0.0" description = "A framework for implementing Python services." readme = "README.md" requires-python = ">=3.11" diff --git a/runtimepy/__init__.py b/runtimepy/__init__.py index 84eb050c..9947f773 100644 --- a/runtimepy/__init__.py +++ b/runtimepy/__init__.py @@ -1,7 +1,7 @@ # ===================================== # generator=datazen # version=3.1.4 -# hash=1f6ecacb506da0891ce4397cd8209529 +# hash=8c2e796a8c4689d3e6713f7e3647ed13 # ===================================== """ @@ -10,7 +10,7 @@ DESCRIPTION = "A framework for implementing Python services." PKG_NAME = "runtimepy" -VERSION = "4.6.1" +VERSION = "5.0.0" # runtimepy-specific content. METRICS_NAME = "metrics" diff --git a/runtimepy/channel/environment/__init__.py b/runtimepy/channel/environment/__init__.py index dec6ec22..3c66d3b5 100644 --- a/runtimepy/channel/environment/__init__.py +++ b/runtimepy/channel/environment/__init__.py @@ -34,6 +34,12 @@ def names(self) -> _Iterator[str]: """Iterate over registered names in the environment.""" yield from self.channels.names.names + def search_names( + self, pattern: str, exact: bool = False + ) -> _Iterator[str]: + """Search for names belonging to this environment.""" + yield from self.channels.names.search(pattern, exact=exact) + def set_default(self, key: str, default: _Default) -> None: """Set a new default value for a channel.""" diff --git a/runtimepy/control/__init__.py b/runtimepy/control/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/runtimepy/control/env/__init__.py b/runtimepy/control/env/__init__.py new file mode 100644 index 00000000..1ef71384 --- /dev/null +++ b/runtimepy/control/env/__init__.py @@ -0,0 +1,56 @@ +""" +A module implementing runtime-environment registration routines for commonly +used control channel types. +""" + +# built-in +from typing import TypeVar + +# internal +from runtimepy.channel.environment import ChannelEnvironment +from runtimepy.primitives import AnyPrimitive +from runtimepy.ui.controls import Controlslike + +T = TypeVar("T", bound=AnyPrimitive) + + +def phase_angle( + env: ChannelEnvironment, + primitive: type[T], + name: str = "phase_angle", + controls: Controlslike = "phase", + **kwargs, +) -> T: + """Create a phase-angle channel.""" + + prim = primitive() + env.channel(name, prim, commandable=True, controls=controls, **kwargs) + return prim # type: ignore + + +def amplitude( + env: ChannelEnvironment, + primitive: type[T], + name: str = "amplitude", + controls: Controlslike = "amplitude", + **kwargs, +) -> T: + """Create an amplitude channel.""" + + prim = primitive() + env.channel(name, prim, commandable=True, controls=controls, **kwargs) + return prim # type: ignore + + +def steps( + env: ChannelEnvironment, + primitive: type[T], + name: str = "steps", + controls: Controlslike = "steps", + **kwargs, +) -> T: + """Create a steps channel.""" + + prim = primitive() + env.channel(name, prim, commandable=True, controls=controls, **kwargs) + return prim # type: ignore diff --git a/runtimepy/control/source.py b/runtimepy/control/source.py new file mode 100644 index 00000000..3c036206 --- /dev/null +++ b/runtimepy/control/source.py @@ -0,0 +1,68 @@ +""" +A module implementing signal source structs. +""" + +# built-in +from abc import abstractmethod +from typing import Any, Generic, cast + +# internal +from runtimepy.control.env import amplitude +from runtimepy.net.arbiter.info import RuntimeStruct +from runtimepy.primitives import Double, T + + +class PrimitiveSource(RuntimeStruct, Generic[T]): + """A simple output-source struct.""" + + kind: type[T] + + outputs: list[T] + amplitudes: list[Double] + + length: int + + def init_source(self) -> None: + """Initialize this value source.""" + + @abstractmethod + def source(self, index: int) -> float | int | bool: + """Provide the next value.""" + + def init_env(self) -> None: + """Initialize this double-source environment.""" + + self.outputs = [] + self.amplitudes = [] + + # Load 'count' from config. + count: int = self.config.get("count", 1) # type: ignore + + for idx in range(count): + # Output channel. + output = self.kind() + self.outputs.append(output) + self.env.channel(f"{idx}.output", output) + + # Amplitude channel. + self.amplitudes.append( + amplitude(self.env, Double, name=f"{idx}.amplitude") + ) + + self.init_source() + self.length = len(self.outputs) + + def poll(self) -> None: + """Update the outputs.""" + + for idx in range(self.length): + # Difficult to avoid cast. + self.outputs[idx].value = cast( + Any, self.amplitudes[idx].value * self.source(idx) + ) + + +class DoubleSource(PrimitiveSource[Double]): + """A simple double output source.""" + + kind = Double diff --git a/runtimepy/control/step.py b/runtimepy/control/step.py new file mode 100644 index 00000000..eeca579d --- /dev/null +++ b/runtimepy/control/step.py @@ -0,0 +1,195 @@ +""" +A module implementing a manual-stepping interface (e.g. program/human +controlled clock). +""" + +# built-in +from typing import cast + +# third-party +from vcorelib.math import ( + RateTracker, + SimulatedTime, + default_time_ns, + from_nanos, + metrics_time_ns, + restore_time_source, + set_simulated_source, +) + +# internal +from runtimepy.net.arbiter import AppInfo +from runtimepy.net.arbiter.info import RuntimeStruct +from runtimepy.net.arbiter.task import ArbiterTask, TaskFactory +from runtimepy.net.manager import ConnectionManager +from runtimepy.net.server.websocket import RuntimepyWebsocketConnection +from runtimepy.primitives import Bool, Double, Uint32 + + +def should_poll(kind: type[RuntimeStruct]) -> bool: + """ + Determine if a toggle stepper should poll the provided type of struct. + """ + return kind.__name__ not in {"ToggleStepper", "UiState"} + + +def refresh_all_plots(manager: ConnectionManager) -> None: + """Signal to clients to refresh all data.""" + + for conn in manager.by_type(RuntimepyWebsocketConnection): + for interface in conn.send_interfaces.values(): + interface({"actions": ["clear_points"]}) + + +class ToggleStepper(RuntimeStruct): + """A simple struct that ties a clock toggle to various runtime entities.""" + + step: Bool + simulate_time: Bool + + time_s: Double + + count: Uint32 + counts: Uint32 + count_rate: Double + count_rate_tracker: RateTracker + + to_poll: set[RuntimeStruct] + + timer: SimulatedTime + + def _poll_time(self) -> None: + """Update current time.""" + self.time_s.value = from_nanos(default_time_ns()) + + def init_env(self) -> None: + """Initialize this toggle stepper environment.""" + + self.step = Bool() + self.simulate_time = Bool() + + self.time_s = Double() + self.env.channel( + "time_s", + self.time_s, + description="Current time (based on default time source).", + ) + self._poll_time() + + self.count = Uint32( + value=cast(int, self.config.get("count", 1)), + time_source=metrics_time_ns, + ) + self.counts = Uint32(time_source=metrics_time_ns) + + self.count_rate = Double(time_source=metrics_time_ns) + self.count_rate_tracker = RateTracker(source=metrics_time_ns) + + self.to_poll = set() + + self.timer = SimulatedTime(cast(int, self.config.get("step_dt_ns", 1))) + + def do_step(_: bool, __: bool) -> None: + """Poll every step edge.""" + + for _ in range(self.count.raw.value): # type: ignore + self.poll() + + self.step.register_callback(do_step) + + self.env.channel( + "step", + self.step, + description="Toggle to drive 'count' iterations forward.", + commandable=True, + ) + self.env.channel( + "count", + self.count, + controls="steps_1_1000", + description="The number of iterations to step.", + commandable=True, + ) + self.env.channel( + "counts", self.counts, description="Total number of counts." + ) + self.env.channel( + "count_rate", + self.count_rate, + description="Counts per second (based on realtime clock).", + ) + + self.env.channel( + "simulate_time", + self.simulate_time, + description=( + "Whether or not time is controlled by the simulated source." + ), + commandable=True, + ) + + def do_simulate_time(_: bool, curr: bool) -> None: + """Toggle the time source selection.""" + + if curr: + set_simulated_source(self.timer) + else: + restore_time_source() + + # Signal to the UI to clear plot data. + refresh_all_plots(self.app.conn_manager) + + self._poll_time() + + self.simulate_time.register_callback(do_simulate_time) + + # Register other structs if configured to do so. + if self.config.get("global", True): + for struct in self.app.structs.values(): + if should_poll(type(struct)): # type: ignore + self.to_poll.add(struct) # type: ignore + + def poll(self) -> None: + """Poll all other entities.""" + + self._poll_time() + + for item in self.to_poll: + item.poll() + + # Bug? + # pylint: disable=no-member + self.counts.value += 1 + # pylint: enable=no-member + self.count_rate.value = self.count_rate_tracker() + + self.timer.step() + + +class ToggleStepperTask(ArbiterTask): + """A task for automatically stepping a toggle-stepper clock.""" + + steppers: list[ToggleStepper] + + async def init(self, app: AppInfo) -> None: + """Initialize this task with application information.""" + + await super().init(app) + self.steppers = list(app.search_structs(ToggleStepper)) + + # Start paused. + self.paused.value = True + + async def dispatch(self) -> bool: + """Dispatch an iteration of this task.""" + + for stepper in self.steppers: + stepper.step.toggle() + + return True + + +class StepperToggler(TaskFactory[ToggleStepperTask]): + """A task factory for toggle stepper tasks.""" + + kind = ToggleStepperTask diff --git a/runtimepy/data/factories.yaml b/runtimepy/data/factories.yaml index c1291525..4cb0019c 100644 --- a/runtimepy/data/factories.yaml +++ b/runtimepy/data/factories.yaml @@ -31,11 +31,14 @@ factories: - {name: runtimepy.task.trig.Sinusoid} - {name: runtimepy.task.sample.Sample} - {name: runtimepy.task.sample.SampleApp} + - {name: runtimepy.control.step.StepperToggler} # Useful structs. - {name: runtimepy.net.arbiter.info.TrigStruct} - {name: runtimepy.net.arbiter.info.SampleStruct} - {name: runtimepy.net.server.struct.UiState} + - {name: runtimepy.control.step.ToggleStepper} + - {name: runtimepy.noise.GaussianSource} # Useful subprocess peer interfaces. - {name: runtimepy.sample.peer.SamplePeer} diff --git a/runtimepy/data/js/classes/TabInterface.js b/runtimepy/data/js/classes/TabInterface.js index eab668f7..d833a5db 100644 --- a/runtimepy/data/js/classes/TabInterface.js +++ b/runtimepy/data/js/classes/TabInterface.js @@ -334,6 +334,20 @@ class TabInterface { } } + /* Handle actions. */ + if ("actions" in data) { + for (let action of data["actions"]) { + switch (action) { + case "clear_points": + this.clearPlotPoints(); + break; + default: + console.log(`Action '${action}' not hangled!`); + break; + } + } + } + /* Stage any channel-table re-paints. */ if (this.channels) { for (let key in data) { diff --git a/runtimepy/data/server_base.yaml b/runtimepy/data/server_base.yaml index 3a5e7e40..c4540271 100644 --- a/runtimepy/data/server_base.yaml +++ b/runtimepy/data/server_base.yaml @@ -10,6 +10,9 @@ ports: structs: - {name: ui, factory: ui_state} +init: + - runtimepy.net.arbiter.housekeeping.init + servers: - factory: runtimepy_http kwargs: {port: "$runtimepy_http_server"} diff --git a/runtimepy/metrics/channel.py b/runtimepy/metrics/channel.py index 2c743a21..e953800b 100644 --- a/runtimepy/metrics/channel.py +++ b/runtimepy/metrics/channel.py @@ -4,6 +4,7 @@ # third-party from vcorelib.math import RateTracker as _RateTracker +from vcorelib.math import metrics_time_ns as _metrics_time_ns # internal from runtimepy.primitives import Float as _Float @@ -21,11 +22,15 @@ def __init__(self) -> None: self.messages = _Uint32() self.message_rate = _Float() - self._message_rate_tracker = _RateTracker(depth=METRICS_DEPTH) + self._message_rate_tracker = _RateTracker( + depth=METRICS_DEPTH, source=_metrics_time_ns + ) self.bytes = _Uint64() self.kbps = _Float() - self._kbps_tracker = _RateTracker(depth=METRICS_DEPTH) + self._kbps_tracker = _RateTracker( + depth=METRICS_DEPTH, source=_metrics_time_ns + ) def update(self, other: "ChannelMetrics") -> None: """Update values in this instance from values in another instance.""" diff --git a/runtimepy/mixins/environment.py b/runtimepy/mixins/environment.py index 291fd84f..3c75e328 100644 --- a/runtimepy/mixins/environment.py +++ b/runtimepy/mixins/environment.py @@ -25,6 +25,10 @@ def __init__(self, env: ChannelEnvironment = None, **kwargs) -> None: env = ChannelEnvironment(**kwargs) self.env = env + def __hash__(self) -> int: + """Get a hash for this instance.""" + return id(self.env) + def register_task_metrics( self, metrics: PeriodicTaskMetrics, namespace: str = METRICS_NAME ) -> None: diff --git a/runtimepy/mixins/regex.py b/runtimepy/mixins/regex.py index 978529e6..7fdf261d 100644 --- a/runtimepy/mixins/regex.py +++ b/runtimepy/mixins/regex.py @@ -12,7 +12,7 @@ from vcorelib.logging import LoggerType DEFAULT_PATTERN = _compile("^[\\w\\:.-]+$") -CHANNEL_PATTERN = _compile("^[a-z0-9_.-]+$") +CHANNEL_PATTERN = _compile("^[a-zA-Z0-9_.-]+$") class RegexMixin: diff --git a/runtimepy/mixins/trig.py b/runtimepy/mixins/trig.py index 5345ec1d..26447d2c 100644 --- a/runtimepy/mixins/trig.py +++ b/runtimepy/mixins/trig.py @@ -7,7 +7,8 @@ # internal from runtimepy.channel.environment import ChannelEnvironment -from runtimepy.primitives import Float as _Float +from runtimepy.control.env import amplitude, phase_angle, steps +from runtimepy.primitives import Float from runtimepy.ui.controls import Controlslike @@ -23,45 +24,22 @@ def __init__( ) -> None: """Initialize this instance.""" - self.sin = _Float() - self.cos = _Float() - self.steps = _Float() - - self.step_angle = float() - + self.sin = Float() + self.cos = Float() env.channel("sin", self.sin) env.channel("cos", self.cos) - env.channel( - "steps", - self.steps, - commandable=True, - controls=steps_controls, - ) + self.step_angle = float() - self.amplitude = _Float() - env.channel( - "amplitude", - self.amplitude, - commandable=True, - controls=amplitude_controls, - ) + self.steps = steps(env, Float, controls=steps_controls) - self.sin_phase_angle = _Float() - self.cos_phase_angle = _Float() + self.amplitude = amplitude(env, Float, controls=amplitude_controls) - env.channel( - "sin_phase_angle", - self.sin_phase_angle, - commandable=True, - controls=phase_angle_controls, + self.sin_phase_angle = phase_angle( + env, Float, name="sin_phase_angle", controls=phase_angle_controls ) - - env.channel( - "cos_phase_angle", - self.cos_phase_angle, - commandable=True, - controls=phase_angle_controls, + self.cos_phase_angle = phase_angle( + env, Float, name="cos_phase_angle", controls=phase_angle_controls ) def update_sin(_: float, __: float) -> None: diff --git a/runtimepy/net/arbiter/base.py b/runtimepy/net/arbiter/base.py index 94a1040a..e5e2ab49 100644 --- a/runtimepy/net/arbiter/base.py +++ b/runtimepy/net/arbiter/base.py @@ -28,7 +28,7 @@ env_json_data, register_env, ) -from runtimepy.net.arbiter.housekeeping import metrics_poller +from runtimepy.net.arbiter.housekeeping import housekeeping from runtimepy.net.arbiter.info import ( AppInfo, ArbiterApps, @@ -102,8 +102,11 @@ def __init__( self.task_manager = _ArbiterTaskManager() # Ensure that connection metrics are polled. - if metrics_poller_task: - self.task_manager.register(metrics_poller(self.manager)) + self.task_manager.register( + housekeeping( + self.manager, poll_connection_metrics=metrics_poller_task + ) + ) if stop_sig is None: stop_sig = _asyncio.Event() diff --git a/runtimepy/net/arbiter/housekeeping/__init__.py b/runtimepy/net/arbiter/housekeeping/__init__.py index 0a06ab42..bfeedd13 100644 --- a/runtimepy/net/arbiter/housekeeping/__init__.py +++ b/runtimepy/net/arbiter/housekeeping/__init__.py @@ -12,6 +12,9 @@ from runtimepy.net.arbiter.task import ArbiterTask as _ArbiterTask from runtimepy.net.arbiter.task import TaskFactory as _TaskFactory from runtimepy.net.manager import ConnectionManager as _ConnectionManager +from runtimepy.primitives import Bool + +TASK_NAME = "housekeeping" class ConnectionMetricsPoller(_ArbiterTask): @@ -28,10 +31,23 @@ def __init__( super().__init__(name, **kwargs) self.manager = manager + def _init_state(self) -> None: + """Add channels to this instance's channel environment.""" + + # Channel control for polling connection metrics. + self.poll_connection_metrics = Bool() + self.env.channel( + "poll_connection_metrics", + self.poll_connection_metrics, + commandable=True, + description="Polls application connection metrics when true.", + ) + async def dispatch(self) -> bool: """Dispatch an iteration of this task.""" - self.manager.poll_metrics() + if self.poll_connection_metrics: + self.manager.poll_metrics() # Handle any incoming commands. processors = [] @@ -46,6 +62,17 @@ async def dispatch(self) -> bool: return True +async def init(app: _AppInfo) -> int: + """Perform some initialization tasks.""" + + for task in app.search_tasks(ConnectionMetricsPoller): + task.poll_connection_metrics.value = app.config_param( + "poll_connection_metrics", False + ) + + return 0 + + class ConnectionMetricsLogger(_ArbiterTask): """A task for logging metrics.""" @@ -73,11 +100,13 @@ class ConnectionMetricsLoggerFactory(_TaskFactory[ConnectionMetricsLogger]): kind = ConnectionMetricsLogger -def metrics_poller( - manager: _ConnectionManager, period_s: float = 0.1 +def housekeeping( + manager: _ConnectionManager, + period_s: float = 0.1, + poll_connection_metrics: bool = True, ) -> ConnectionMetricsPoller: """Create a metrics-polling task.""" - return ConnectionMetricsPoller( - "connection_metrics_poller", manager, period_s=period_s - ) + task = ConnectionMetricsPoller(TASK_NAME, manager, period_s=period_s) + task.poll_connection_metrics.value = poll_connection_metrics + return task diff --git a/runtimepy/net/arbiter/info.py b/runtimepy/net/arbiter/info.py index 2c287b58..33be7c6b 100644 --- a/runtimepy/net/arbiter/info.py +++ b/runtimepy/net/arbiter/info.py @@ -62,6 +62,9 @@ async def build(self, app: "AppInfo") -> None: self.init_env() +W = _TypeVar("W", bound=RuntimeStruct) + + class TrigStruct(RuntimeStruct, TrigMixin): """A simple trig struct.""" @@ -197,6 +200,17 @@ def search_tasks(self, kind: type[V], pattern: str = ".*") -> _Iterator[V]: if compiled.search(name) is not None and isinstance(task, kind): yield task + def search_structs( + self, kind: type[W], pattern: str = ".*" + ) -> _Iterator[W]: + """Search for structs by type or name.""" + + compiled = _compile(pattern) + + for name, task in self.structs.items(): + if compiled.search(name) is not None and isinstance(task, kind): + yield task + def single( self, *names: str, diff --git a/runtimepy/net/manager.py b/runtimepy/net/manager.py index 15b54195..f3e1b0ef 100644 --- a/runtimepy/net/manager.py +++ b/runtimepy/net/manager.py @@ -14,7 +14,7 @@ # third-party from vcorelib.asyncio import log_exceptions as _log_exceptions from vcorelib.logging import LoggerMixin -from vcorelib.math import default_time_ns as _default_time_ns +from vcorelib.math import metrics_time_ns as _metrics_time_ns # internal from runtimepy.net.connection import Connection as _Connection @@ -53,7 +53,7 @@ def poll_metrics(self, time_ns: int = None) -> None: """Poll connection metrics.""" if time_ns is None: - time_ns = _default_time_ns() + time_ns = _metrics_time_ns() for conn in self._conns: conn.metrics.poll(time_ns=time_ns) diff --git a/runtimepy/net/server/app/bootstrap/elements.py b/runtimepy/net/server/app/bootstrap/elements.py index df4ee6df..c607d711 100644 --- a/runtimepy/net/server/app/bootstrap/elements.py +++ b/runtimepy/net/server/app/bootstrap/elements.py @@ -142,7 +142,12 @@ def slider( elem["min"] = min_val elem["max"] = max_val - elem["step"] = (max_val - min_val) / steps + + step = (max_val - min_val) / steps + if isinstance(min_val, int) and isinstance(max_val, int): + step = int(step) + + elem["step"] = step # add tick marks - didn't seem to work (browser didn't render anything) diff --git a/runtimepy/net/server/json.py b/runtimepy/net/server/json.py index c5697e02..b8c879cb 100644 --- a/runtimepy/net/server/json.py +++ b/runtimepy/net/server/json.py @@ -27,27 +27,14 @@ def default(self, o): return o -def json_handler( - stream: TextIO, - request: RequestHeader, - response: ResponseHeader, - request_data: Optional[bytes], - data: JsonObject, -) -> None: - """Create an HTTP response from some JSON object data.""" - - del request_data - - response_type = "json" - response["Content-Type"] = ( - f"application/{response_type}; charset={DEFAULT_ENCODING}" - ) +def traverse_dict(data: dict[str, Any], *paths: str) -> Any: + """Attempt to traverse a dictionary by path names.""" error: dict[str, Any] = {"path": {}} # Traverse path. curr_path = [] - for part in request.target.path.split("/")[2:]: + for part in paths: if not part: continue @@ -72,11 +59,33 @@ def json_handler( data = error break - data = data[part] # type: ignore + data = data[part] if callable(data): data = data() + return data + + +def json_handler( + stream: TextIO, + request: RequestHeader, + response: ResponseHeader, + request_data: Optional[bytes], + data: JsonObject, +) -> None: + """Create an HTTP response from some JSON object data.""" + + del request_data + + response_type = "json" + response["Content-Type"] = ( + f"application/{response_type}; charset={DEFAULT_ENCODING}" + ) + + # Traverse path. + data = traverse_dict(data, *request.target.path.split("/")[2:]) + # Use a convention for indexing data to non-dictionary leaf nodes. if not isinstance(data, dict): data = {"__raw__": data} diff --git a/runtimepy/net/server/websocket/__init__.py b/runtimepy/net/server/websocket/__init__.py index 211a63af..d4c8c376 100644 --- a/runtimepy/net/server/websocket/__init__.py +++ b/runtimepy/net/server/websocket/__init__.py @@ -5,6 +5,9 @@ # built-in from collections import defaultdict +# third-party +from vcorelib.math import RateLimiter, metrics_time_ns, to_nanos + # internal from runtimepy.message import JsonMessage from runtimepy.net.arbiter.tcp.json import WebsocketJsonMessageConnection @@ -22,10 +25,7 @@ class RuntimepyWebsocketConnection(WebsocketJsonMessageConnection): ui_time: float tabs: dict[str, TabState] - # The first UI client has exclusive access to some functions, like - # polling metrics. - first_client: bool - first_message: bool + poll_governor: RateLimiter def tab_sender(self, name: str) -> TabMessageSender: """Get a tab message-sending interface.""" @@ -44,7 +44,7 @@ def _poll_ui_state(self, ui: UiState, time: float) -> None: """Update UI-specific state.""" # Only one connection needs to perform this task. - if self.first_client or ui.env.value("num_connections") == 1: + if self.poll_governor(): # Update time. ui.env["time_ms"] = time ui.env["frame_period_ms"] = time - self.ui_time @@ -63,15 +63,6 @@ def _register_handlers(self) -> None: async def ui_handler(outbox: JsonMessage, inbox: JsonMessage) -> None: """A simple loopback handler.""" - # Add to num_connections. - if self.first_message: - ui = UiState.singleton() - if ui and ui.env.finalized: - self.first_client = ( - ui.env.add_int("num_connections", 1) == 1 - ) - self.first_message = False - # Handle frame messages. if "time" in inbox: # Poll UI state. @@ -109,8 +100,11 @@ def init(self) -> None: self.send_interfaces = {} self.ui_time = 0.0 self.tabs = defaultdict(TabState.create) - self.first_client = False - self.first_message = True + + # Limit UI metrics update rate to 250 Hz. + self.poll_governor = RateLimiter( + to_nanos(1.0 / 250.0), source=metrics_time_ns + ) def disable_extra(self) -> None: """Additional tasks to perform when disabling.""" diff --git a/runtimepy/noise/__init__.py b/runtimepy/noise/__init__.py new file mode 100644 index 00000000..a849c6fe --- /dev/null +++ b/runtimepy/noise/__init__.py @@ -0,0 +1,19 @@ +""" +A module implementing a structure that simulates a configurable noise source. +""" + +# built-in +import random + +# internal +from runtimepy.control.source import DoubleSource + + +class GaussianSource(DoubleSource): + """A simple output-source struct.""" + + def source(self, index: int) -> float: + """Provide the next value.""" + + del index + return random.gauss() diff --git a/runtimepy/primitives/base.py b/runtimepy/primitives/base.py index 5af456b8..131614c1 100644 --- a/runtimepy/primitives/base.py +++ b/runtimepy/primitives/base.py @@ -13,7 +13,8 @@ from typing import TypeVar as _TypeVar # third-party -from vcorelib.math.time import default_time_ns, nano_str +from vcorelib.math import default_time_ns, nano_str +from vcorelib.math.keeper import TimeSource # internal from runtimepy.primitives.byte_order import ( @@ -49,7 +50,10 @@ def __hash__(self) -> int: return self._hash def __init__( - self, value: T = None, scaling: ChannelScaling = None + self, + value: T = None, + scaling: ChannelScaling = None, + time_source: TimeSource = default_time_ns, ) -> None: """Initialize this primitive.""" @@ -58,8 +62,9 @@ def __init__( self.callbacks: dict[int, tuple[PrimitiveChangeCallaback[T], bool]] = ( {} ) + self.time_source = time_source self(value=value) - self.last_updated_ns: int = default_time_ns() + self.last_updated_ns: int = self.time_source() self.scaling = scaling self._hash = IDENT() @@ -72,7 +77,7 @@ def age_ns(self, now: int = None) -> int: """Get the age of this primitive's value in nanoseconds.""" if now is None: - now = default_time_ns() + now = self.time_source() return now - self.last_updated_ns @@ -136,7 +141,7 @@ def value(self, value: T) -> None: curr: T = self.raw.value # type: ignore self.raw.value = value - self.last_updated_ns = default_time_ns() + self.last_updated_ns = self.time_source() # Call callbacks if the value has changed. if self.callbacks and curr != value: diff --git a/runtimepy/primitives/bool.py b/runtimepy/primitives/bool.py index 4446b408..9ab3547e 100644 --- a/runtimepy/primitives/bool.py +++ b/runtimepy/primitives/bool.py @@ -13,9 +13,9 @@ class BooleanPrimitive(_Primitive[bool]): kind = _Bool value: bool - def __init__(self, value: bool = False) -> None: + def __init__(self, value: bool = False, **kwargs) -> None: """Initialize this boolean primitive.""" - super().__init__(value=value) + super().__init__(value=value, **kwargs) def toggle(self) -> None: """Toggle the underlying value.""" diff --git a/runtimepy/primitives/float.py b/runtimepy/primitives/float.py index 0cdc988f..f9bfdf6c 100644 --- a/runtimepy/primitives/float.py +++ b/runtimepy/primitives/float.py @@ -16,10 +16,10 @@ class HalfPrimitive(_Primitive[float]): kind = _Half def __init__( - self, value: float = 0.0, scaling: ChannelScaling = None + self, value: float = 0.0, scaling: ChannelScaling = None, **kwargs ) -> None: """Initialize this floating-point primitive.""" - super().__init__(value=value, scaling=scaling) + super().__init__(value=value, scaling=scaling, **kwargs) Half = HalfPrimitive @@ -31,10 +31,10 @@ class FloatPrimitive(_Primitive[float]): kind = _Float def __init__( - self, value: float = 0.0, scaling: ChannelScaling = None + self, value: float = 0.0, scaling: ChannelScaling = None, **kwargs ) -> None: """Initialize this floating-point primitive.""" - super().__init__(value=value, scaling=scaling) + super().__init__(value=value, scaling=scaling, **kwargs) Float = FloatPrimitive @@ -46,10 +46,10 @@ class DoublePrimitive(_Primitive[float]): kind = _Double def __init__( - self, value: float = 0.0, scaling: ChannelScaling = None + self, value: float = 0.0, scaling: ChannelScaling = None, **kwargs ) -> None: """Initialize this floating-point primitive.""" - super().__init__(value=value, scaling=scaling) + super().__init__(value=value, scaling=scaling, **kwargs) Double = DoublePrimitive diff --git a/runtimepy/primitives/int.py b/runtimepy/primitives/int.py index 31944c61..66b57dd7 100644 --- a/runtimepy/primitives/int.py +++ b/runtimepy/primitives/int.py @@ -23,9 +23,11 @@ class Int8Primitive(_Primitive[int]): kind = _Int8 - def __init__(self, value: int = 0, scaling: ChannelScaling = None) -> None: + def __init__( + self, value: int = 0, scaling: ChannelScaling = None, **kwargs + ) -> None: """Initialize this integer primitive.""" - super().__init__(value=value, scaling=scaling) + super().__init__(value=value, scaling=scaling, **kwargs) Int8 = Int8Primitive @@ -36,9 +38,11 @@ class Int16Primitive(_Primitive[int]): kind = _Int16 - def __init__(self, value: int = 0, scaling: ChannelScaling = None) -> None: + def __init__( + self, value: int = 0, scaling: ChannelScaling = None, **kwargs + ) -> None: """Initialize this integer primitive.""" - super().__init__(value=value, scaling=scaling) + super().__init__(value=value, scaling=scaling, **kwargs) Int16 = Int16Primitive @@ -49,9 +53,11 @@ class Int32Primitive(_Primitive[int]): kind = _Int32 - def __init__(self, value: int = 0, scaling: ChannelScaling = None) -> None: + def __init__( + self, value: int = 0, scaling: ChannelScaling = None, **kwargs + ) -> None: """Initialize this integer primitive.""" - super().__init__(value=value, scaling=scaling) + super().__init__(value=value, scaling=scaling, **kwargs) Int32 = Int32Primitive @@ -62,9 +68,11 @@ class Int64Primitive(_Primitive[int]): kind = _Int64 - def __init__(self, value: int = 0, scaling: ChannelScaling = None) -> None: + def __init__( + self, value: int = 0, scaling: ChannelScaling = None, **kwargs + ) -> None: """Initialize this integer primitive.""" - super().__init__(value=value, scaling=scaling) + super().__init__(value=value, scaling=scaling, **kwargs) Int64 = Int64Primitive @@ -75,9 +83,11 @@ class Uint8Primitive(_Primitive[int]): kind = _Uint8 - def __init__(self, value: int = 0, scaling: ChannelScaling = None) -> None: + def __init__( + self, value: int = 0, scaling: ChannelScaling = None, **kwargs + ) -> None: """Initialize this integer primitive.""" - super().__init__(value=value, scaling=scaling) + super().__init__(value=value, scaling=scaling, **kwargs) Uint8 = Uint8Primitive @@ -88,9 +98,11 @@ class Uint16Primitive(_Primitive[int]): kind = _Uint16 - def __init__(self, value: int = 0, scaling: ChannelScaling = None) -> None: + def __init__( + self, value: int = 0, scaling: ChannelScaling = None, **kwargs + ) -> None: """Initialize this integer primitive.""" - super().__init__(value=value, scaling=scaling) + super().__init__(value=value, scaling=scaling, **kwargs) Uint16 = Uint16Primitive @@ -101,9 +113,11 @@ class Uint32Primitive(_Primitive[int]): kind = _Uint32 - def __init__(self, value: int = 0, scaling: ChannelScaling = None) -> None: + def __init__( + self, value: int = 0, scaling: ChannelScaling = None, **kwargs + ) -> None: """Initialize this integer primitive.""" - super().__init__(value=value, scaling=scaling) + super().__init__(value=value, scaling=scaling, **kwargs) Uint32 = Uint32Primitive @@ -114,9 +128,11 @@ class Uint64Primitive(_Primitive[int]): kind = _Uint64 - def __init__(self, value: int = 0, scaling: ChannelScaling = None) -> None: + def __init__( + self, value: int = 0, scaling: ChannelScaling = None, **kwargs + ) -> None: """Initialize this integer primitive.""" - super().__init__(value=value, scaling=scaling) + super().__init__(value=value, scaling=scaling, **kwargs) Uint64 = Uint64Primitive diff --git a/runtimepy/requirements.txt b/runtimepy/requirements.txt index f05e2eb0..37e7a78f 100644 --- a/runtimepy/requirements.txt +++ b/runtimepy/requirements.txt @@ -1,4 +1,4 @@ -vcorelib>=3.2.6 +vcorelib>=3.2.8 svgen>=0.6.7 websockets psutil diff --git a/runtimepy/struct/dither.py b/runtimepy/struct/dither.py deleted file mode 100644 index a800700b..00000000 --- a/runtimepy/struct/dither.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -A module implementing a structure for a dithering controller. -""" diff --git a/runtimepy/subprocess/protocol.py b/runtimepy/subprocess/protocol.py index 26db8af1..a1c2272e 100644 --- a/runtimepy/subprocess/protocol.py +++ b/runtimepy/subprocess/protocol.py @@ -15,7 +15,7 @@ from typing import Optional, cast # third-party -from vcorelib.math import default_time_ns +from vcorelib.math import metrics_time_ns class RuntimepySubprocessProtocol(SubprocessProtocol): @@ -56,7 +56,7 @@ def pid(self) -> int: def connection_made(self, transport) -> None: """Initialize this protocol.""" - self.start_time = default_time_ns() + self.start_time = metrics_time_ns() self.elapsed_time = -1 self.stdout_queue = None @@ -105,7 +105,7 @@ def _flush_stderr(self) -> None: def process_exited(self) -> None: """Handle process exit.""" - self.elapsed_time = default_time_ns() - self.start_time + self.elapsed_time = metrics_time_ns() - self.start_time self._flush_stdout() self._flush_stderr() diff --git a/runtimepy/task/basic/periodic.py b/runtimepy/task/basic/periodic.py index 58b72487..f8007c9a 100644 --- a/runtimepy/task/basic/periodic.py +++ b/runtimepy/task/basic/periodic.py @@ -65,10 +65,10 @@ def __init__( self.register_task_metrics(self.metrics) # State. - self._paused = _Bool() + self.paused = _Bool() self.env.channel( "paused", - self._paused, + self.paused, commandable=True, description="Whether or not this task is paused.", ) @@ -147,7 +147,7 @@ async def run( while self._enabled: # When paused, don't run the iteration itself. - if not self._paused: + if not self.paused: with self.metrics.measure( eloop, self._dispatch_rate, diff --git a/runtimepy/task/sample.py b/runtimepy/task/sample.py index adf1d572..df5a9f6b 100644 --- a/runtimepy/task/sample.py +++ b/runtimepy/task/sample.py @@ -16,7 +16,7 @@ class SampleTask(ArbiterTask, TrigMixin): - """A base TUI application.""" + """A sample application.""" async def init(self, app: AppInfo) -> None: """Initialize this task with application information.""" @@ -52,7 +52,7 @@ async def dispatch(self) -> bool: class Sample(TaskFactory[SampleTask]): - """A TUI application factory.""" + """A sample-task application factory.""" kind = SampleTask diff --git a/runtimepy/ui/controls.py b/runtimepy/ui/controls.py index 5d92d02a..0f0cf23b 100644 --- a/runtimepy/ui/controls.py +++ b/runtimepy/ui/controls.py @@ -32,14 +32,15 @@ def make_slider( CANONICAL: dict[str, Controls] = { "phase": make_slider(-math.pi, math.pi, 90, default=0.0), + "amplitude": make_slider(0.0, 2.0, 100.0, default=1.0), + "period": make_slider(0.0, 0.05, 100.0, default=0.01), "steps": make_slider( DEFAULT_STEPS / 4, DEFAULT_STEPS * 4, DEFAULT_STEPS * 2, default=DEFAULT_STEPS, ), - "amplitude": make_slider(0.0, 2.0, 40.0, default=1.0), - "period": make_slider(0.0, 0.05, 100.0, default=0.01), + "steps_1_1000": make_slider(1, 1000, 100, default=1), } diff --git a/tasks/noise.yaml b/tasks/noise.yaml new file mode 100644 index 00000000..4265b75a --- /dev/null +++ b/tasks/noise.yaml @@ -0,0 +1,13 @@ +--- +includes: + - default.yaml + +tasks: + - {name: clock_task, factory: stepper_toggler} + +structs: + - name: clock + factory: toggle_stepper + - name: noise + factory: gaussian_source + config: {count: 4} diff --git a/tests/channel/environment/test_create.py b/tests/channel/environment/test_create.py index d6cc9136..b50d2329 100644 --- a/tests/channel/environment/test_create.py +++ b/tests/channel/environment/test_create.py @@ -26,6 +26,8 @@ def test_channel_environment_create_basic(): ) assert result + assert len(list(env.search_names("sample_channel", exact=True))) == 1 + env.age_ns("sample_channel") name = "test_field" diff --git a/tests/commands/test_arbiter.py b/tests/commands/test_arbiter.py index 8cbd3ead..161223f3 100644 --- a/tests/commands/test_arbiter.py +++ b/tests/commands/test_arbiter.py @@ -26,7 +26,7 @@ def test_arbiter_command_basic(): == 0 ) - for entry in ["basic", "http"]: + for entry in ["basic", "http", "control"]: assert ( runtimepy_main( base + [str(resource("connection_arbiter", f"{entry}.yaml"))] diff --git a/tests/control/__init__.py b/tests/control/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/control/test_step.py b/tests/control/test_step.py new file mode 100644 index 00000000..39056a4a --- /dev/null +++ b/tests/control/test_step.py @@ -0,0 +1,33 @@ +""" +Test the 'control.step' module. +""" + +# module under test +from runtimepy.control.step import ToggleStepper, ToggleStepperTask +from runtimepy.net.arbiter.info import AppInfo + + +async def controls_test(app: AppInfo) -> int: + """Test JSON clients in parallel.""" + + toggler = list(app.search_tasks(ToggleStepperTask))[0] + toggler.paused.value = False + + stepper = list(app.search_structs(ToggleStepper))[0] + + stepper.step.toggle() + stepper.step.toggle() + + stepper.simulate_time.toggle() + + stepper.step.toggle() + stepper.step.toggle() + + stepper.simulate_time.toggle() + + stepper.step.toggle() + stepper.step.toggle() + + assert await toggler.dispatch() + + return 0 diff --git a/tests/data/valid/connection_arbiter/control.yaml b/tests/data/valid/connection_arbiter/control.yaml new file mode 100644 index 00000000..c5a3697b --- /dev/null +++ b/tests/data/valid/connection_arbiter/control.yaml @@ -0,0 +1,16 @@ +--- +includes: + - package://runtimepy/factories.yaml + +tasks: + - {name: clock_task, factory: stepper_toggler} + +structs: + - name: clock + factory: toggle_stepper + - name: noise + factory: gaussian_source + config: {count: 4} + +app: + - tests.control.test_step.controls_test diff --git a/tests/data/valid/connection_arbiter/runtimepy_http.yaml b/tests/data/valid/connection_arbiter/runtimepy_http.yaml index 6a98840a..111f8975 100644 --- a/tests/data/valid/connection_arbiter/runtimepy_http.yaml +++ b/tests/data/valid/connection_arbiter/runtimepy_http.yaml @@ -5,6 +5,12 @@ includes_left: app: - tests.net.stream.runtimepy_http_test +structs: + - name: clock + factory: toggle_stepper + - name: noise + factory: gaussian_source + config: experimental: true foo: bar diff --git a/tests/net/server/test_json.py b/tests/net/server/test_json.py new file mode 100644 index 00000000..b0f1ae76 --- /dev/null +++ b/tests/net/server/test_json.py @@ -0,0 +1,15 @@ +""" +Test the 'net.server.json' module. +""" + +# module under test +from runtimepy.net.server.json import traverse_dict + + +def test_traverse_dict_basic(): + """Test basic dict-traversing scenarios.""" + + src = {"a": lambda: {"a": 1, "b": 2, "c": 3}} + + assert traverse_dict(src, "", "a") == {"a": 1, "b": 2, "c": 3} + assert traverse_dict(src, "a", "b") == 2 diff --git a/tests/net/stream/__init__.py b/tests/net/stream/__init__.py index 1ed9aee8..31a8cf3c 100644 --- a/tests/net/stream/__init__.py +++ b/tests/net/stream/__init__.py @@ -13,6 +13,7 @@ # module under test from runtimepy import PKG_NAME +from runtimepy.control.step import ToggleStepper from runtimepy.message import JsonMessage from runtimepy.net.arbiter.info import AppInfo from runtimepy.net.http.header import RequestHeader @@ -110,6 +111,11 @@ async def runtimepy_http_test(app: AppInfo) -> int: app.single(pattern="client", kind=RuntimepyWebsocketConnection) ) + # Find stepper struct, toggle 'simualte_time' twice. + for inst in app.search_structs(ToggleStepper): + inst.simulate_time.toggle() + inst.simulate_time.toggle() + return 0