diff --git a/discos_client/initializer.py b/discos_client/initializer.py index c73d218..19d5f75 100644 --- a/discos_client/initializer.py +++ b/discos_client/initializer.py @@ -573,6 +573,33 @@ def _enrich_named_property( out["value"] = value return out + def _collect_init_keys( + self, + schema: dict[str, Any] + ) -> tuple[set[str], set[str]]: + """ + Recursively collect all ``required`` and ``initialize`` fields declared + in a schema, including those defined inside ``anyOf`` branches. + + :param schema: A JSON Schema object, potentially containing ``anyOf`` + branches and local ``required`` / ``initialize`` + sections. + :return: A tuple where each element is a set of field names + aggregated from the entire schema hierarchy. + """ + required: set[str] = set(schema.get("required", [])) + initialize: set[str] = set(schema.get("initialize", [])) + + any_of = schema.get("anyOf") + if isinstance(any_of, list): + for alt in any_of: + if isinstance(alt, dict): + r_alt, i_alt = self._collect_init_keys(alt) + required |= r_alt + initialize |= i_alt + + return required, initialize + def _initialize_from_schema( self, schema: dict[str, Any] @@ -589,13 +616,13 @@ def _initialize_from_schema( :param schema: Fully normalized JSON schema. :return: Initial structured payload used to construct a namespace. """ - required: set[str] = set(schema.get("required", [])) - initialize: set[str] = set(schema.get("initialize", [])) - fake_values: dict[str, Any] = {} + required, initialize = self._collect_init_keys(schema) result: dict[str, Any] = {} for key in required.union(initialize): prop_schema = self._find_property_schema(schema, key) + if prop_schema is None: # pragma: no cover + continue prop_schema = self._replace_patterns_with_properties( prop_schema, {} @@ -603,7 +630,7 @@ def _initialize_from_schema( result[key] = self._enrich_named_property( key, prop_schema, - fake_values + {} ) meta = self._meta(schema) meta.update(result) @@ -627,4 +654,11 @@ def _find_property_schema( props = schema.get("properties", {}) if key in props: return props[key] + any_of = schema.get("anyOf") + if isinstance(any_of, list): + for alt in any_of: + if isinstance(alt, dict): + found = self._find_property_schema(alt, key) + if found is not None: + return found return None # pragma: no cover diff --git a/discos_client/schemas/common/derotators.json b/discos_client/schemas/common/derotators.json new file mode 100644 index 0000000..0ca129d --- /dev/null +++ b/discos_client/schemas/common/derotators.json @@ -0,0 +1,163 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "common/derotators.json", + "title": "Derotators status", + "type": "object", + "description": "Status of the telescope derotators.", + "node": "derotators", + "$defs": { + "derotator": { + "title": "Derotator", + "type": "object", + "description": "Derotator status.", + "node": "derotators.", + "properties": { + "commandedPosition": { + "type": "number", + "title": "Commanded Position", + "description": "Commanded position of the derotator", + "unit": "degrees" + }, + "currentPosition": { + "type": "number", + "title": "Current Position", + "description": "Current position of the derotator.", + "unit": "degrees" + }, + "maxLimit": { + "type": "number", + "title": "Max Limit", + "description": "Maximum limit of the derotator's range.", + "unit": "degrees" + }, + "minLimit": { + "type": "number", + "title": "Min Limit", + "description": "Minimum limit of the derotator's range", + "unit": "degrees" + }, + "ready": { + "type": "boolean", + "title": "Ready", + "description": "Indicates whether the derotator is ready or not." + }, + "rewindingStep": { + "type": "number", + "title": "Rewinding Step", + "description": "Derotator's angle between two external feeds.", + "unit": "degrees" + }, + "slewing": { + "type": "boolean", + "title": "Slewing", + "description": "Indicates whether the derotator is currently slewing." + }, + "timestamp": { + "$ref": "../definitions/timestamp.json" + }, + "tracking": { + "type": "boolean", + "title": "Tracking", + "description": "Indicates whether the derotator is tracking the commanded position." + }, + "trackingError": { + "type": "number", + "title": "Tracking Error", + "description": "Derotator tracking error.", + "unit": "degrees" + } + }, + "required": [ + "commandedPosition", + "currentPosition", + "maxLimit", + "minLimit", + "ready", + "rewindingStep", + "slewing", + "timestamp", + "tracking", + "trackingError" + ] + } + }, + "anyOf": [ + { + "type": "object", + "properties": { + "currentConfiguration": { + "type": "string", + "title": "Current Configuration", + "description": "Currently selected derotator configuration." + }, + "currentDerotator": { + "type": "string", + "title": "Current Derotator", + "description": "Currently selected derotator's name." + }, + "currentSetup": { + "type": "string", + "title": "Current setup", + "description": "Current DISCOS setup code." + }, + "rewinding": { + "type": "boolean", + "title": "Rewinding", + "description": "Indicates whether the current derotator is rewinding." + }, + "rewindingMode": { + "type": "string", + "title": "Rewinding Mode", + "description": "The derotator positioner rewinding mode.", + "enum": [ + "AUTO", + "MANUAL", + "NONE" + ] + }, + "rewindingRequired": { + "type": "boolean", + "title": "Rewinding Required", + "description": "Indicates whether the current derotator requires to be rewinded." + }, + "status": { + "$ref": "../definitions/status.json" + }, + "timestamp": { + "$ref": "../definitions/timestamp.json" + }, + "tracking": { + "type": "boolean", + "title": "Tracking", + "description": "Indicates whether the current derotator is tracking the commanded position." + }, + "updating": { + "type": "boolean", + "title": "Updating", + "description": "Indicates whether the derotator positioner is updating the position." + } + }, + "required": [ + "currentConfiguration", + "currentDerotator", + "currentSetup", + "rewinding", + "rewindingMode", + "rewindingRequired", + "status", + "timestamp", + "tracking", + "updating" + ] + }, + { + "type": "object", + "patternProperties": { + "^[A-Za-z0-9_]+$": { "$ref": "#/$defs/derotator" } + }, + "minProperties": 1, + "maxProperties": 1, + "additionalProperties": false + } + ] +} diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 11ad28b..bf83458 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -15,3 +15,24 @@ .rst-content table.docutils th { white-space: pre-wrap; } +.rst-content table.docutils tr.row-border-0 td, +.rst-content table.docutils tr.row-border-0 th, +.rst-content table.docutils tr.row-border-1 td:nth-child(n+2), +.rst-content table.docutils tr.row-border-1 th:nth-child(n+2), +.rst-content table.docutils tr.row-border-2 td:nth-child(n+3), +.rst-content table.docutils tr.row-border-2 th:nth-child(n+3) +{ + border-top: 2px solid #777; + font-size: 0 !important; + padding-top: 0 !important; + padding-bottom: 0 !important; +} +.rst-content table.docutils tr.row-hidden-0 td, +.rst-content table.docutils tr.row-hidden-0 th, +.rst-content table.docutils tr.row-hidden-1 td:nth-child(n+2), +.rst-content table.docutils tr.row-hidden-1 th:nth-child(n+2), +.rst-content table.docutils tr.row-hidden-2 td:nth-child(n+3), +.rst-content table.docutils tr.row-hidden-2 th:nth-child(n+3) +{ + display: none; +} diff --git a/docs/conf.py b/docs/conf.py index 001b9cc..0d415b3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -5,7 +5,7 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__))) sys.path.insert(0, os.path.abspath('../discos_client')) -from patches import _simpletype, _complexstructures, _reference +from patches import _simpletype, _complexstructures, _reference, _transform # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information @@ -93,3 +93,4 @@ def setup(app): sjs_wide_format.WideFormat._simpletype = _simpletype sjs_wide_format.WideFormat._complexstructures = _complexstructures sjs_wide_format.WideFormat._reference = _reference +sjs_wide_format.WideFormat.transform = _transform diff --git a/docs/patches.py b/docs/patches.py index 857b289..f24a1fd 100644 --- a/docs/patches.py +++ b/docs/patches.py @@ -2,8 +2,73 @@ import sys from pathlib import Path from urllib.parse import urlparse +from docutils import nodes sys.path.insert(0, os.path.abspath('../discos_client')) +SEP = "__SEP__" +SEP_TOP = "__SEP_TOP__" +SEP_BOTTOM = "__SEP_BOTTOM__" + + +def _transform(self, schema): + body, definitions = self._dispatch(schema) + table = None + if len(body) > 0: + cols, head, body = self._cover(schema, body) + table = self.state.build_table((cols, head, body), self.lineno) + if table is None: + return table, definitions + + for tbody in table.traverse(nodes.tbody): + levels = {} + for idx, row in enumerate(tbody.children): + if not isinstance(row, nodes.row): + continue + entries = [c for c in row.children if isinstance(c, nodes.entry)] + if not entries: + continue + cell_texts = [e.astext().strip() for e in entries] + if SEP_TOP in cell_texts: + marker = SEP_TOP + elif SEP in cell_texts: + marker = SEP + elif SEP_BOTTOM in cell_texts: + marker = SEP_BOTTOM + else: + continue + sep_index = next( + (i for i, txt in enumerate(cell_texts) if marker in txt), + 0 + ) + level = max(0, min(sep_index, 4)) + levels[idx] = level + for e in entries: + for child in list(e.children): + if marker in e.astext(): + e.remove(child) + consecutives = consecutive_groups(list(levels.keys())) + for lst in consecutives: + level = levels[lst[-1]] + tbody.children[lst[-1]]['classes'].append(f"row-border-{level}") + for idx in lst[:-1]: + l = levels[idx] + tbody.children[idx]['classes'].append(f"row-hidden-{l}") + return table, definitions + +def consecutive_groups(lst): + if not lst: + return [] + groups = [] + current = [lst[0]] + for x in lst[1:]: + if x == current[-1] + 1: + current.append(x) + else: + groups.append(current) + current = [x] + groups.append(current) + return groups + def _simpletype(self, schema): """Render the *extra* ``units`` schema property for every object.""" rows = [] @@ -45,22 +110,36 @@ def _simpletype(self, schema): rows.extend(self._kvpairs(schema, self.KV_SIMPLE)) return rows + def _complexstructures(self, schema): rows = [] - for k in self.COMBINATORS: - # combinators belong at this level as alternative to type if k in schema: - items = [] - for s in schema[k]: - content = self._dispatch(s)[0] - if content: + if k in ['anyOf', 'allOf']: + if k == 'anyOf': + label = self._cell('any of the following') + else: + label = self._cell('all the properties of') + items = [] + items.append(self._line(self._cell(SEP_TOP))) + for idx, s in enumerate(schema[k]): + content = self._dispatch(s)[0] + if not content: + continue + if idx > 0: + items.append(self._line(self._cell(SEP))) items.extend(content) - if items: - key = k - if k == 'allOf': - key = 'all properties of' - rows.extend(self._prepend(self._cell(key), items)) + if items: + items.append(self._line(self._cell(SEP_BOTTOM))) + rows.extend(self._prepend(label, items)) + else: + items = [] + for s in schema[k]: + content = self._dispatch(s)[0] + if content: + items.extend(content) + if items: + rows.extend(self._prepend(self._cell(k), items)) del schema[k] for k in self.SINGLEOBJECTS: @@ -70,7 +149,7 @@ def _complexstructures(self, schema): del schema[k] if self.CONDITIONAL[0] in schema: - # only if 'if' in schema there would be a needs to go through if, then & else + # only if 'if' in schema there would be a need to go through if, then & else items = [] for k in self.CONDITIONAL: if k in schema: @@ -81,7 +160,6 @@ def _complexstructures(self, schema): if len(items) >= 2: for item in items: rows.extend(item) - return rows diff --git a/docs/schemas/backends.rst b/docs/schemas/backends.rst index 0aa1726..9121afd 100644 --- a/docs/schemas/backends.rst +++ b/docs/schemas/backends.rst @@ -1,8 +1,5 @@ Backends -------- -.. jsonschema:: ../../discos_client/schemas/common/backends.json#/$defs/backend - :hide_key: /**/$id - -.. jsonschema:: ../../discos_client/schemas/common/backends.json#/$defs/channel +.. jsonschema:: ../../discos_client/schemas/common/backends.json :hide_key: /**/$id diff --git a/docs/schemas/derotators.rst b/docs/schemas/derotators.rst new file mode 100644 index 0000000..afcc78d --- /dev/null +++ b/docs/schemas/derotators.rst @@ -0,0 +1,5 @@ +Derotators +---------- + +.. jsonschema:: ../../discos_client/schemas/common/derotators.json + :hide_key: /**/$id diff --git a/docs/schemas/schemas.rst b/docs/schemas/schemas.rst index 1ae561b..1a80b1e 100644 --- a/docs/schemas/schemas.rst +++ b/docs/schemas/schemas.rst @@ -22,6 +22,7 @@ More details regarding each schema can be found in the following sections. active_surface antenna backends + derotators minor_servo mount receivers diff --git a/tests/messages/common/backends.json b/tests/messages/common/backends.json new file mode 100644 index 0000000..e189ccf --- /dev/null +++ b/tests/messages/common/backends.json @@ -0,0 +1 @@ +{"TotalPower":{"backendTime":{"iso8601":"2025-12-05T15:38:58.000Z","mjd":61014.65206018509,"omg_time":139842419380000000,"unix_time":1764949138.0},"busy":false,"channels":[{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":0,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":1,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":2,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":3,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":4,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":5,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":6,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":7,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":8,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":9,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":10,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":11,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":12,"polarization":"LHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0},{"attenuation":7.0,"bandWidth":2000.0,"bins":1,"id":13,"polarization":"RHCP","sampleRate":2.5e-05,"startFrequency":50.0,"systemTemperature":0.0}],"commandLineError":false,"dataLineError":false,"integration":40,"sampling":false,"suspended":false,"timeSync":true,"timestamp":{"iso8601":"2025-12-05T15:38:58.627Z","mjd":61014.65206744196,"omg_time":139842419386276140,"unix_time":1764949138.627614}}} diff --git a/tests/messages/common/derotators.json b/tests/messages/common/derotators.json new file mode 100644 index 0000000..49c61ec --- /dev/null +++ b/tests/messages/common/derotators.json @@ -0,0 +1 @@ +{"DR_GFR1":{"commandedPosition":136.93804590474818,"currentPosition":136.937158,"maxLimit":142.5,"minLimit":-117.5,"ready":true,"rewindingStep":60.0,"slewing":true,"timestamp":{"iso8601":"2025-12-05T21:40:58.558Z","mjd":61014.90345553262,"omg_time":139842636585581120,"unix_time":1764970858.558112},"tracking":true,"trackingError":0.0008879047481684665},"currentConfiguration":"BSC","currentDerotator":"DR_GFR1","currentSetup":"KKG","rewinding":false,"rewindingMode":"AUTO","rewindingRequired":false,"status":"OK","timestamp":{"iso8601":"2025-12-05T21:40:58.536Z","mjd":61014.903455277905,"omg_time":139842636585361332,"unix_time":1764970858.5361333},"tracking":true,"updating":true} diff --git a/tests/test_client.py b/tests/test_client.py index da7887a..fd119ac 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -33,6 +33,19 @@ def __init__(self, telescope=None): with open(message, "r", encoding="utf-8") as f: topic_name = message.stem self.messages[topic_name] = json.load(f) + self.timestamps = [] + + def recurse(obj): + if isinstance(obj, dict): + if "unix_time" in obj: + self.timestamps.append(obj) + for v in obj.values(): + recurse(v) + elif isinstance(obj, list): + for item in obj: + recurse(item) + for payload in self.messages.values(): + recurse(payload) self.t = Thread(target=self.publish) self.event = Event() self.t.start() @@ -80,8 +93,9 @@ def _handle_subscription(self): ]) def _send_periodic_messages(self): + for timestamp in self.timestamps: + timestamp["unix_time"] = time.time() for topic, payload in self.messages.items(): - payload["timestamp"]["unix_time"] = time.time() if "." in topic: topic, obj = topic.split(".", 1) payload = {obj: payload}