Skip to content

Commit dce953c

Browse files
committed
Clean up compare_reference_attributes CHECK function and adjust optimizer schema accordingly
1 parent 4bbba4c commit dce953c

3 files changed

Lines changed: 321 additions & 154 deletions

File tree

esdlvalidator/validation/functions/check_attributes_not_null_or_valid.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ def get_function_definition(self):
7373
"type": "string",
7474
"description": "Dot-separated reference path (e.g., costInformation.investmentCosts)",
7575
},
76-
# To locate the target reference entity when reference is of EOrderedSet type.
76+
# To locate the target reference entity when reference is of EOrderedSet type (e.g., port).
7777
"ref_list_filter": {
7878
"type": "object",
7979
"required": ["is_type"],
@@ -159,7 +159,7 @@ def execute(self):
159159

160160
return CheckResult(True)
161161

162-
def _resolve_reference(self, entity: any, ref: dict, results: list):
162+
def _resolve_reference(self, entity: object, ref: dict, results: list):
163163
if not ref:
164164
return entity
165165

Lines changed: 167 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import jsonschema
22
from jsonschema.exceptions import ValidationError
33

4-
from esdlvalidator.core.esdl import utils as esdlUtils
4+
from esdlvalidator.core.esdl import utils
55

66
from esdlvalidator.validation.functions import utils
77
from esdlvalidator.validation.functions.function import (
@@ -15,194 +15,227 @@
1515

1616
from pyecore.ecore import EOrderedSet
1717

18-
# TODO: check ports are connected to 1 hydraulically coupled network
1918

2019
@FunctionFactory.register(FunctionType.CHECK, "compare_reference_attributes")
2120
class CompareRefAttributes(FunctionCheck):
21+
"""
22+
Compares two attributes from either the input entity or its nested references.
23+
24+
Supported operators:
25+
- greater_than
26+
- less_than
27+
- equal
28+
29+
Each side (left/right) may specify a reference path and filter to locate the target entity.
30+
"""
2231

2332
def get_function_definition(self):
2433
return FunctionDefinition(
2534
"compare_reference_attributes",
26-
"TODO",
35+
"Compare two attributes from the located references (or from the entity itself) using a specified operator.",
2736
[
2837
ArgDefinition(
29-
"ref",
30-
"TODO",
38+
"left",
39+
"Reference and attribute to compare on the left side.",
3140
True,
3241
),
3342
ArgDefinition(
3443
"operator",
35-
"TODO",
36-
True,
37-
),
38-
ArgDefinition(
39-
"left",
40-
"TODO",
44+
"Comparison operator",
4145
True,
4246
),
4347
ArgDefinition(
4448
"right",
45-
"TODO",
49+
"Reference and attribute to compare on the right side",
4650
True,
4751
),
48-
ArgDefinition("resultMsgJSON", "Display output in JSON format", False),
52+
ArgDefinition("resultMsgJSON", "If True, returns results in structured JSON format.", False),
4953
],
5054
)
5155

56+
NOT_FOUND = "Not found"
57+
UNSET = "Unset"
5258
# Supported operator types. Can be extended in future if required.
5359
OPERATOR_ENUM = ["greater_than", "equal", "less_than"]
5460

5561
args_schema = {
5662
"type": "object",
57-
"required": ["ref", "operator", "left", "right"],
63+
"required": ["left", "operator", "right"],
5864
"properties": {
59-
"ref": {"type": "string"},
65+
"left": {"$ref": "#/$defs/refAttribute"},
6066
"operator": {"type": "string", "enum": OPERATOR_ENUM},
61-
"left": {
67+
"right": {"$ref": "#/$defs/refAttribute"},
68+
"resultMsgJSON": {"type": "boolean"},
69+
},
70+
"$defs": {
71+
"refAttribute": {
6272
"type": "object",
63-
"required": ["ref_filter", "attribute"],
73+
"required": ["attribute"],
6474
"properties": {
65-
"ref_filter": {
75+
# If ref is not specified, it refers to the input entity.
76+
"ref": {
6677
"type": "object",
67-
"required": ["is_type", "match"],
68-
"properties": {"is_type": {"type": "string"}, "match": {"type": "object"}},
78+
"description": "Optional reference filter to locate neseted reference entity.",
79+
"required": ["path"],
80+
"properties": {
81+
"path": {
82+
"type": "string",
83+
"description": "Dot-separated reference path (e.g., costInformation.investmentCosts)",
84+
},
85+
# To locate the target reference entity when reference is of EOrderedSet type (e.g., port).
86+
"ref_list_filter": {
87+
"type": "object",
88+
"required": ["is_type"],
89+
"properties": {
90+
"is_type": {"type": "string"},
91+
"match": {
92+
"type": "object",
93+
"description": "Optional attribute filters",
94+
},
95+
},
96+
"additionalProperties": False,
97+
},
98+
},
99+
"additionalProperties": False,
69100
},
70-
"attribute": {"type": "string"},
71-
},
72-
},
73-
"right": {
74-
"type": "object",
75-
"required": ["ref_filter", "attribute"],
76-
"properties": {
77-
"ref_filter": {
78-
"type": "object",
79-
"required": ["is_type", "match"],
80-
"properties": {"is_type": {"type": "string"}, "match": {"type": "object"}},
101+
"attribute": {
102+
"type": "string",
103+
"description": "Attribute name to extract from the (nested reference) entity",
81104
},
82-
"attribute": {"type": "string"},
83105
},
84-
},
85-
"resultMsgJSON": {"type": "boolean"},
106+
"additionalProperties": False,
107+
}
86108
},
87109
}
88110

89-
NOT_FOUND = "Not found"
90-
UNSET = "Unset"
91-
92111
def execute(self):
93112
entity = self.value
94113
args_dict = self.args
95-
96114
results = []
97115

98116
try:
99117
jsonschema.validate(instance=args_dict, schema=self.args_schema)
100118
except ValidationError as e:
101119
raise ValueError(f"Schema validation failed at {list(e.path)}: {e.message}")
102120

103-
ref = utils.get_attribute(self.args, "ref")
104121
operator = utils.get_attribute(self.args, "operator")
105-
# TODO: rename to ref_args?
106-
left = utils.get_attribute(self.args, "left")
107-
right = utils.get_attribute(self.args, "right")
122+
left_args = utils.get_attribute(self.args, "left")
123+
right_args = utils.get_attribute(self.args, "right")
108124
resultMsgJSON = utils.get_attribute(self.args, "resultMsgJSON")
109125

110-
# Check if the retrieved asset references are valid
111-
references = utils.get_attr_or_ref_attr(entity, ref)
112-
if references == self.NOT_FOUND:
113-
raise ValueError(f"[{ref}] not found.")
114-
if references == self.UNSET:
115-
results.append(f"[{ref}] should be defined, but is unset.")
116-
elif not isinstance(references, EOrderedSet):
117-
raise TypeError(f"Expect [{ref}] to be of EOrderedSet type, found {type(references)}")
118-
else:
119-
left_entity = self.get_ref(references, left["ref_filter"])
120-
right_entity = self.get_ref(references, right["ref_filter"])
121-
122-
for entity_ref, side_args in [(left_entity, left), (right_entity, right)]:
123-
if entity_ref is None:
124-
match_str = ", ".join(f"[{k}] = '{v}'" for k, v in side_args["ref_filter"]["match"].items())
125-
results.append(
126-
f"[{ref}] should contain a [{side_args['ref_filter']['is_type']}] ({match_str}), but not found."
127-
)
128-
129-
if left_entity and right_entity:
130-
left_val = self.get_attribute_value(left_entity, left, results)
131-
right_val = self.get_attribute_value(right_entity, right, results)
132-
133-
if left_val is not None and right_val is not None:
134-
try:
135-
left_num = float(left_val)
136-
right_num = float(right_val)
137-
except (TypeError, ValueError):
138-
# TODO: message
139-
results.append(f"Cannot compare non-numeric values: left='{left_val}', right='{right_val}'")
140-
else:
141-
if operator == "greater_than" and left_num <= right_num:
142-
results.append(
143-
f"[{left['attribute']}] (vale: {left_num}) of [{left['ref_filter']['is_type']}] should be greater than [{right['attribute']}] (value: {right_val}) of [{right['ref_filter']['is_type']}]."
144-
)
145-
elif operator == "less_than" and left_num >= right_num:
146-
results.append(
147-
f"[{left['attribute']}] (vale: {left_num}) of [{left['ref_filter']['is_type']}] should be less than [{right['attribute']}] (value: {right_val}) of [{right['ref_filter']['is_type']}]."
148-
)
149-
elif operator == "equal" and left_num != right_num:
150-
results.append(
151-
f"[{left['attribute']}] (vale: {left_num}) of [{left['ref_filter']['is_type']}] should be equal to [{right['attribute']}] (value: {right_val}) of [{right['ref_filter']['is_type']}]."
152-
)
153-
154-
if len(results) > 0:
155-
if resultMsgJSON:
156-
msg = {"offending_asset": utils.get_attribute(entity, "id"), "message": results}
157-
return CheckResult(False, msg)
126+
left_entity = self._resolve_reference(entity, left_args.get("ref"), results)
127+
right_entity = self._resolve_reference(entity, right_args.get("ref"), results)
128+
129+
left_val = self._get_attribute_value(left_entity, left_args, results)
130+
right_val = self._get_attribute_value(right_entity, right_args, results)
131+
132+
if left_val is not None and right_val is not None:
133+
results.extend(self._compare_values(left_val, right_val, operator, left_args, right_args))
134+
135+
if results:
136+
msg = (
137+
{"offending_asset": utils.get_attribute(entity, "id"), "message": results} if resultMsgJSON else results
138+
)
139+
return CheckResult(False, msg)
140+
return CheckResult(True)
141+
142+
def _resolve_reference(self, entity: object, ref: dict, results: list):
143+
"""Resolves the target entity from a reference path and optional filter."""
144+
145+
if not ref:
146+
return entity
147+
148+
ref_path = ref["path"]
149+
reference = utils.get_attr_or_ref_attr(entity, ref_path)
150+
151+
if reference == self.NOT_FOUND:
152+
raise ValueError(f"[{ref_path}] not found.")
153+
if reference == self.UNSET:
154+
results.append(f"[{ref_path}] should be defined, but is unset.")
155+
return None
156+
157+
if isinstance(reference, EOrderedSet):
158+
if len(reference) > 1 and ref.get("ref_list_filter"):
159+
entity_to_check = utils.get_ref(reference, ref["ref_list_filter"])
158160
else:
159-
return CheckResult(False, results)
160-
else:
161-
return CheckResult(True)
162-
163-
def get_ref(self, references: EOrderedSet, ref_filter: dict):
164-
"""
165-
Filters a set of ESDL references to find the first entity matching the given type and attribute criteria.
166-
167-
Args:
168-
references: A collection of ESDL entities.
169-
ref_filter: A dictionary with keys:
170-
- 'is_type': the expected ESDL type as a string.
171-
- 'match': a dictionary of attribute names and expected values.
172-
173-
Returns:
174-
The first matching entity, or None if no match is found.
175-
"""
176-
ref_type = ref_filter["is_type"]
177-
match_dict = ref_filter["match"]
178-
esdlType = esdlUtils.get_esdl_class_from_string(ref_type)[0]
179-
180-
for entity in references:
181-
if not isinstance(entity, esdlType):
182-
continue
183-
if all(utils.get_attr_or_ref_attr(entity, key) == value for key, value in match_dict.items()):
184-
return entity
185-
186-
return None
187-
188-
def get_attribute_value(self, entity, args: dict, results):
189-
filter = args["ref_filter"]
190-
attr_path = args["attribute"]
161+
entity_to_check = reference[0]
162+
163+
if entity_to_check is None:
164+
match_str = ", ".join(f"[{k}] = '{v}'" for k, v in ref["ref_list_filter"].get("match", {}).items())
165+
results.append(
166+
f"[{ref_path}] should contain a [{ref['ref_list_filter']['is_type']}] ({match_str}), but not found."
167+
)
168+
return entity_to_check
169+
170+
return reference
171+
172+
def _get_attribute_value(self, entity: object, attr_args: dict, results: list):
173+
"""Retrieves the attribute value from the given entity and logs unset or missing cases."""
174+
175+
attr_path = attr_args["attribute"]
191176
value = utils.get_attr_or_ref_attr(entity, attr_path)
192177

193178
if value == self.NOT_FOUND:
194-
raise ValueError(f"[{attr_path}] not found.")
179+
raise ValueError(f"{self._format_attr_context(attr_args)} not found.")
195180
if value == self.UNSET:
196-
# TODO: rename variable
197-
# msg_head = f"[{attr_path}]"
198-
199-
match_str = ", ".join(f"{k}={v}" for k, v in filter["match"].items())
200-
msg_head = f"[{filter['is_type']}] ({match_str})"
201-
# if len(attr_path.split(".")) > 1:
202-
# parent_path = attr_path.split(".")[-2]
203-
# parent_value = utils.get_attr_or_ref_attr(entity, parent_path)
204-
# if parent_path and parent_value.name:
205-
# msg_head = f"[{attr_path.split('.')[-1]}] of [{parent_path}] ({parent_value.name})"
206-
results.append(f"{msg_head} should have [{attr_path}] defined, but is unset.")
181+
results.append(f"{self._format_attr_context(attr_args)} should be defined, but is unset.")
207182
return None
208183
return value
184+
185+
def _format_attr_context(self, attr_args: dict):
186+
"""Generates a context-aware label for an attribute used in error messages."""
187+
188+
attr = attr_args["attribute"]
189+
ref = attr_args.get("ref")
190+
191+
if not ref:
192+
return f"[{attr}]"
193+
194+
ref_type = ref.get("ref_list_filter", {}).get("is_type")
195+
match = ref.get("ref_list_filter", {}).get("match", {})
196+
197+
match_str = ""
198+
if match:
199+
match_str = " with " + ", ".join(f"[{k}] = '{v}'" for k, v in match.items())
200+
201+
return f"[{attr}] from [{ref_type}]{match_str}"
202+
203+
def _compare_values(self, left_val, right_val, operator, left_args, right_args):
204+
"""Compares two numeric values using the specified operator and returns result messages."""
205+
206+
results = []
207+
try:
208+
left_num = float(left_val)
209+
right_num = float(right_val)
210+
except (TypeError, ValueError):
211+
results.append(f"Cannot compare non-numeric values: left='{left_val}', right='{right_val}'")
212+
return results
213+
214+
left_desc = self._format_side_description(left_args, left_num)
215+
right_desc = self._format_side_description(right_args, right_num)
216+
217+
if operator == "greater_than" and left_num <= right_num:
218+
results.append(f"{left_desc} should be greater than {right_desc}.")
219+
elif operator == "less_than" and left_num >= right_num:
220+
results.append(f"{left_desc} should be less than {right_desc}.")
221+
elif operator == "equal" and left_num != right_num:
222+
results.append(f"{left_desc} should be equal to {right_desc}.")
223+
return results
224+
225+
def _format_side_description(self, attr_args: dict, value: float):
226+
"""Formats a descriptive string for an attribute value based on its reference context."""
227+
228+
attr = attr_args["attribute"]
229+
ref = attr_args.get("ref")
230+
231+
if not ref:
232+
return f"[{attr}] (value: {value})"
233+
234+
ref_type = ref.get("ref_list_filter", {}).get("is_type")
235+
match = ref.get("ref_list_filter", {}).get("match", {})
236+
237+
match_str = ""
238+
if match:
239+
match_str = " with " + ", ".join(f"[{k}] = '{v}'" for k, v in match.items())
240+
241+
return f"[{attr}] (value: {value}) from [{ref_type}]{match_str}"

0 commit comments

Comments
 (0)