diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cc92392..42476161 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,8 @@ jobs: wget -O /tmp/ifcopenshell_python.zip "https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-311-v0.8.6-8b5b400-linux64.zip" mkdir -p venv/lib/python3.11/site-packages unzip -d venv/lib/python3.11/site-packages /tmp/ifcopenshell_python.zip + rm /tmp/ifcopenshell_python.zip + make install-ifcopenshell-mvd IFCOPENSHELL_SITE_PACKAGES=venv/lib/python3.11/site-packages - name: Check Django config run: | diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index d57d7532..2d0cd8ad 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -101,6 +101,8 @@ jobs: wget -O /tmp/ifcopenshell_python.zip "https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-311-v0.8.6-8b5b400-linux64.zip" mkdir -p venv/lib/python3.11/site-packages unzip -d venv/lib/python3.11/site-packages /tmp/ifcopenshell_python.zip + rm /tmp/ifcopenshell_python.zip + make install-ifcopenshell-mvd IFCOPENSHELL_SITE_PACKAGES=venv/lib/python3.11/site-packages - name: Check Django config run: | diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 0062c459..efb3447b 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -110,6 +110,7 @@ jobs: mkdir -p .dev/venv/lib/python3.11/site-packages unzip -o -d .dev/venv/lib/python3.11/site-packages /tmp/ifcopenshell_python.zip rm /tmp/ifcopenshell_python.zip + make install-ifcopenshell-mvd IFCOPENSHELL_SITE_PACKAGES=.dev/venv/lib/python3.11/site-packages # Verify installation ls -la .dev/venv/lib/python3.11/site-packages/ echo "Checking for ifcopenshell installation:" diff --git a/backend/Makefile b/backend/Makefile index 10d00476..7c06fe1e 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -3,6 +3,8 @@ VIRTUAL_ENV = .dev/venv PYTHON = $(VIRTUAL_ENV)/bin/python PIP = $(VIRTUAL_ENV)/bin/pip +IFCOPENSHELL_SITE_PACKAGES = $(VIRTUAL_ENV)/lib/python3.11/site-packages +PYTHON_MVDXML_REF = support-4.x-graphviz-format-2 none: @echo "MAKE: Enter at least one target (venv, install, install-dev, start-backend, start-worker, clean)" @@ -15,25 +17,38 @@ install: venv $(PIP) install --upgrade pip find . -name 'requirements.txt' -exec $(PIP) install -r {} \; wget -O /tmp/ifcopenshell_python.zip "https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-311-v0.8.6-8b5b400-linux64.zip" - mkdir -p $(VIRTUAL_ENV)/lib/python3.11/site-packages - unzip -o -d $(VIRTUAL_ENV)/lib/python3.11/site-packages /tmp/ifcopenshell_python.zip + mkdir -p $(IFCOPENSHELL_SITE_PACKAGES) + unzip -o -d $(IFCOPENSHELL_SITE_PACKAGES) /tmp/ifcopenshell_python.zip rm /tmp/ifcopenshell_python.zip + $(MAKE) install-ifcopenshell-mvd install-macos: venv find . -name 'requirements.txt' -exec $(PIP) install -r {} \; $(PIP) install -r requirements.txt wget -O /tmp/ifcopenshell_python.zip "https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-311-v0.8.6-8b5b400-macos64.zip" - mkdir -p $(VIRTUAL_ENV)/lib/python3.11/site-packages - unzip -o -d $(VIRTUAL_ENV)/lib/python3.11/site-packages /tmp/ifcopenshell_python.zip + mkdir -p $(IFCOPENSHELL_SITE_PACKAGES) + unzip -o -d $(IFCOPENSHELL_SITE_PACKAGES) /tmp/ifcopenshell_python.zip rm /tmp/ifcopenshell_python.zip + $(MAKE) install-ifcopenshell-mvd install-macos-m1: venv find . -name 'requirements.txt' -exec $(PIP) install -r {} \; $(PIP) install -r requirements.txt wget -O /tmp/ifcopenshell_python.zip "https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-311-v0.8.6-8b5b400-macosm164.zip" - mkdir -p $(VIRTUAL_ENV)/lib/python3.11/site-packages - unzip -o -d $(VIRTUAL_ENV)/lib/python3.11/site-packages /tmp/ifcopenshell_python.zip + mkdir -p $(IFCOPENSHELL_SITE_PACKAGES) + unzip -o -d $(IFCOPENSHELL_SITE_PACKAGES) /tmp/ifcopenshell_python.zip rm /tmp/ifcopenshell_python.zip + $(MAKE) install-ifcopenshell-mvd + +.PHONY: install-ifcopenshell-mvd +install-ifcopenshell-mvd: + test -d "$(IFCOPENSHELL_SITE_PACKAGES)/ifcopenshell" + mvd_tmp=$$(mktemp -d) + trap 'rm -rf "$$mvd_tmp"' EXIT + wget -O "$$mvd_tmp/python-mvdxml.tar.gz" "https://github.com/opensourceBIM/python-mvdxml/archive/refs/heads/$(PYTHON_MVDXML_REF).tar.gz" + rm -rf "$(IFCOPENSHELL_SITE_PACKAGES)/ifcopenshell/mvd" + mkdir -p "$(IFCOPENSHELL_SITE_PACKAGES)/ifcopenshell/mvd" + tar -xzf "$$mvd_tmp/python-mvdxml.tar.gz" --strip-components=1 -C "$(IFCOPENSHELL_SITE_PACKAGES)/ifcopenshell/mvd" fetch-modules: cd ./apps && git submodule update --init --recursive diff --git a/backend/apps/ifc_validation/admin.py b/backend/apps/ifc_validation/admin.py index 540e46a7..5fac4d7f 100644 --- a/backend/apps/ifc_validation/admin.py +++ b/backend/apps/ifc_validation/admin.py @@ -1,4 +1,5 @@ import logging +from urllib.parse import urlencode from django.urls import path from django.contrib import admin @@ -6,11 +7,13 @@ from django.contrib.auth import get_permission_codename from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import User +from django.core.exceptions import FieldError, PermissionDenied from django.http import HttpResponseRedirect from django.shortcuts import render from django.urls import reverse from django.utils.translation import ngettext from django.utils.html import format_html +from django.db import DatabaseError from django.db.models import F, Case, When, DurationField, Count from django.db.models.functions import Now from django import forms @@ -20,6 +23,9 @@ from apps.ifc_validation_models.models import ValidationTask from apps.ifc_validation_models.models import ValidationOutcome from apps.ifc_validation_models.models import Model +from apps.ifc_validation_models.models import EntityCountHistogram +from apps.ifc_validation_models.models import PsetCountHistogram +from apps.ifc_validation_models.models import TemplateStatistic from apps.ifc_validation_models.models import ModelInstance from apps.ifc_validation_models.models import Company from apps.ifc_validation_models.models import AuthoringTool @@ -36,6 +42,16 @@ from core import utils from core.filters import AdvancedDateFilter +from .statistics_query import ( + StatisticsQueryClauseFormSet, + StatisticsQueryBuilder, + StatisticsSourceForm, + build_statistics_specification, + bind_statistics_query_form_data, + format_sql, + model_histogram_query, + statistics_query_ui_context, +) logger = logging.getLogger(__name__) @@ -503,7 +519,7 @@ class ModelAdmin(BaseAdmin, NonAdminAddable): ('Auditing Information', {"classes": ("wide"), "fields": [("created",), ("updated")]}) ] - list_display = ["id", "public_id", "file_name", "size_text", "authoring_tool_link", "schema", "mvd", "timestamp", "header_file_name", "is_signed", "created", "updated"] + list_display = ["id", "public_id", "file_name", "size_text", "authoring_tool_link", "schema", "mvd", "timestamp", "header_file_name", "is_signed", "histogram_link", "pset_histogram_link", "created", "updated"] readonly_fields = ["id", "public_id", "file", "file_name", "size", "size_text", "date", "schema", "mvd", "produced_by", "created", "updated", "status_schema_calculated"] date_hierarchy = "created" @@ -515,7 +531,86 @@ class ModelAdmin(BaseAdmin, NonAdminAddable): ('date', AdvancedDateFilter), ('created', AdvancedDateFilter) ] - + + def get_urls(self): + urls = super().get_urls() + custom = [ + path( + "statistics/", + self.admin_site.admin_view(self.statistics_view), + name="ifc_validation_models_model_statistics", + ), + ] + return custom + urls + + @admin.display(description="Entities") + def histogram_link(self, obj): + link = reverse("admin:ifc_validation_models_model_statistics") + link = f"{link}?{urlencode({'source': 'entity', 'model': obj.pk})}" + return format_html('View', link) + + @admin.display(description="Property Sets") + def pset_histogram_link(self, obj): + link = reverse("admin:ifc_validation_models_model_statistics") + link = f"{link}?{urlencode({'source': 'pset', 'model': obj.pk})}" + return format_html('View', link) + + def statistics_view(self, request): + if not self.has_view_permission(request): + raise PermissionDenied + + preset_query = None + if ( + request.method == "GET" + and request.GET.get("source") in {"entity", "pset"} + and request.GET.get("model") + ): + preset_query = model_histogram_query( + request.GET["source"], + request.GET["model"], + ) + data = ( + request.POST + if request.method == "POST" + else bind_statistics_query_form_data(preset_query) if preset_query else None + ) + source_form = StatisticsSourceForm(data) + clause_formset = StatisticsQueryClauseFormSet( + data, + prefix="clauses", + ) + result = None + query_error = "" + forms_are_valid = ( + data is not None + and source_form.is_valid() + and clause_formset.is_valid() + ) + if forms_are_valid: + try: + specification = build_statistics_specification( + source_form.cleaned_data["source"], + clause_formset, + ) + result = StatisticsQueryBuilder(specification).execute() + except (DatabaseError, FieldError, RuntimeError, ValueError) as error: + query_error = str(error) + + context = { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "title": "Model statistics query builder", + "source_form": source_form, + "clause_formset": clause_formset, + "query_error": query_error, + "columns": result.columns if result else [], + "rows": result.rows if result else [], + "display_rows": result.display_rows if result else [], + "sql": result.sql if result else "", + **statistics_query_ui_context(), + } + return TemplateResponse(request, "admin/model_statistics.html", context) + @admin.display(description="File Size", ordering='size') def size_text(self, obj): @@ -569,6 +664,22 @@ class ModelInstanceAdmin(BaseAdmin, NonAdminAddable): show_full_result_count = False # do not use COUNT(*) twice +class EntityCountHistogramAdmin(admin.ModelAdmin): + readonly_fields = ["entity_name"] + + @admin.display(description="Entity name") + def entity_name(self, obj): + return obj.entity_name + + +class PsetCountHistogramAdmin(admin.ModelAdmin): + readonly_fields = ["entity_name"] + + @admin.display(description="Entity name") + def entity_name(self, obj): + return obj.entity_name + + class CompanyAdmin(BaseAdmin): fieldsets = [ @@ -900,20 +1011,7 @@ def get_row(field): try: qs = entry.build().apply(ValidationOutcome.objects.filter(pk=outcome_id)) - sql = str(qs.query) - try: - # This is most likely a transitive dependency from django, but - # if somehow unavailable it doesn't matter - import sqlparse - sql = sqlparse.format( - sql, - reindent=True, - keyword_case="upper", - identifier_case=None, - ) - except: - pass - result["sql"] = sql + result["sql"] = format_sql(str(qs.query)) except Exception as e: result["error"] = str(e) else: @@ -946,6 +1044,9 @@ class WhiteListTestForm(forms.Form): admin.site.register(ValidationTask, ValidationTaskAdmin) admin.site.register(ValidationOutcome, ValidationOutcomeAdmin) admin.site.register(Model, ModelAdmin) +admin.site.register(EntityCountHistogram, EntityCountHistogramAdmin) +admin.site.register(PsetCountHistogram, PsetCountHistogramAdmin) +admin.site.register(TemplateStatistic) admin.site.register(ModelInstance, ModelInstanceAdmin) admin.site.register(Company, CompanyAdmin) admin.site.register(AuthoringTool, AuthoringToolAdmin) diff --git a/backend/apps/ifc_validation/checks/statistics/apply_mvd.py b/backend/apps/ifc_validation/checks/statistics/apply_mvd.py new file mode 100644 index 00000000..fe53d5d4 --- /dev/null +++ b/backend/apps/ifc_validation/checks/statistics/apply_mvd.py @@ -0,0 +1,73 @@ +import sys +from pathlib import Path + +import ifcopenshell +from ifcopenshell.mvd import template + + +TEMPLATES_DIR = Path(__file__).parent / "templates" + + +def available_template_names(templates_dir=TEMPLATES_DIR): + return tuple( + markdown.name for markdown in sorted(Path(templates_dir).glob("*.md")) + ) + + +def json_value(value): + if isinstance(value, ifcopenshell.entity_instance): + return value.is_a() + if isinstance(value, (list, tuple)): + return [json_value(item) for item in value] + return value + + +def extract_template_statistics( + file_or_path, + templates_dir=TEMPLATES_DIR, + template_names=None, +): + model = ( + file_or_path + if isinstance(file_or_path, ifcopenshell.file) + else ifcopenshell.open(file_or_path) + ) + results = [] + + selected_template_names = ( + None if template_names is None else set(template_names) + ) + for markdown in sorted(Path(templates_dir).glob("*.md")): + if ( + selected_template_names is not None + and markdown.name not in selected_template_names + ): + continue + concept = template.from_graphviz(markdown.read_text(encoding="utf-8")) + try: + focus_instances = model.by_type(concept.entity) + except RuntimeError: + focus_instances = () + + for focus in focus_instances: + rows = concept.extract(focus) + if not rows: + continue + graph = { + concept.binding_for(key) or key.attribute: json_value(value) + for row in rows + for key, value in row.items() + } + results.append({ + "template": markdown.name, + "focus_step_id": focus.id(), + "focus_ifc_type": focus.is_a(), + "graph": graph, + }) + + return results + + +if __name__ == "__main__": + for result in extract_template_statistics(sys.argv[1]): + print(result) diff --git a/backend/apps/ifc_validation/checks/statistics/templates/BasisCurves_of_IfcPointByDistanceExpression.md b/backend/apps/ifc_validation/checks/statistics/templates/BasisCurves_of_IfcPointByDistanceExpression.md new file mode 100644 index 00000000..9b96db78 --- /dev/null +++ b/backend/apps/ifc_validation/checks/statistics/templates/BasisCurves_of_IfcPointByDistanceExpression.md @@ -0,0 +1,14 @@ +# BasisCurves of IfcPointByDistanceExpression + +> SCOPE IFC4.3+ + +Per the schema IfcPointByDistanceExpression.BasisCurve is of type IfcCurve +which includes a broad range of subtypes, most of which do not make sense in +the context of linear referencing. + +``` +concept { + IfcPointByDistanceExpression:BasisCurve -> IfcCurve + IfcPointByDistanceExpression:BasisCurve[binding="BasisCurve"] +} +``` diff --git a/backend/apps/ifc_validation/checks/statistics/templates/Usage_of_IfcPropertySetDefinitionSet.md b/backend/apps/ifc_validation/checks/statistics/templates/Usage_of_IfcPropertySetDefinitionSet.md new file mode 100644 index 00000000..b6060976 --- /dev/null +++ b/backend/apps/ifc_validation/checks/statistics/templates/Usage_of_IfcPropertySetDefinitionSet.md @@ -0,0 +1,13 @@ +# Usage of IfcPropertySetDefinitionSet + +> SCOPE IFC4+ + +IFC4 introduced a mechanism by which the objectified relationship for property +set association obtained a select type between a single set and a set of sets. +This template selects the second category. + +```text +concept { + IfcRelDefinesByProperties:RelatingPropertyDefinition -> IfcPropertySetDefinitionSet +} +``` diff --git a/backend/apps/ifc_validation/checks/statistics/templates/Usage_of_bylayer_IfcCurveStyle.md b/backend/apps/ifc_validation/checks/statistics/templates/Usage_of_bylayer_IfcCurveStyle.md new file mode 100644 index 00000000..357d5113 --- /dev/null +++ b/backend/apps/ifc_validation/checks/statistics/templates/Usage_of_bylayer_IfcCurveStyle.md @@ -0,0 +1,13 @@ +# Usage of 'by layer' IfcCurveStyle + +> SCOPE IFC2X3+ + +There has been a bug in the IfcOpenShell rule execution that caused spaces within string literals of express rules to get dropped. Therefore IfcCurveStyle_WR11 execution was wrong. This template uncovers whether this pattern is present in vendor-created uploads, informing us whether it is safe to update the validation service logic. + +```text +concept { + IfcCurveStyle:CurveWidth -> IfcDescriptiveMeasure + IfcDescriptiveMeasure -> constraint_0 + constraint_0[label="=by layer"] +} +``` diff --git a/backend/apps/ifc_validation/checks/statistics/templates/Usage_of_transition_curves_geometry.md b/backend/apps/ifc_validation/checks/statistics/templates/Usage_of_transition_curves_geometry.md new file mode 100644 index 00000000..dc0758a8 --- /dev/null +++ b/backend/apps/ifc_validation/checks/statistics/templates/Usage_of_transition_curves_geometry.md @@ -0,0 +1,12 @@ +# Usage of transition curves - geometry + +> SCOPE IFC4.3+ + +This is a template to uncover statistics on usage of the various kinds of (transition) curves and their usage context in alignment geometry. The check is on the geometric level. + +```text +concept { + IfcCurveSegment:ParentCurve -> IfcCurve + IfcCurveSegment:ParentCurve[binding="ParentCurve"] +} +``` diff --git a/backend/apps/ifc_validation/checks/statistics/templates/Usage_of_transition_curves_semantics.md b/backend/apps/ifc_validation/checks/statistics/templates/Usage_of_transition_curves_semantics.md new file mode 100644 index 00000000..e395c13f --- /dev/null +++ b/backend/apps/ifc_validation/checks/statistics/templates/Usage_of_transition_curves_semantics.md @@ -0,0 +1,27 @@ +# Usage of transition curves - semantics + +> SCOPE IFC4.3+ + +This is a template to uncover statistics on usage of the various kinds of (transition) curves and their usage context in alignment geometry. The check is on the business logic level. + +```text +concept { + IfcAlignment:IsNestedBy -> IfcRelNests_0:RelatingObject + IfcRelNests_0:RelatedObjects -> IfcLinearElement + IfcLinearElement:IsNestedBy -> IfcRelNests_1:RelatingObject + IfcRelNests_1:RelatedObjects -> IfcAlignmentSegment_0 + + IfcAlignmentSegment_0:DesignParameters -> IfcAlignmentHorizontalSegment + IfcAlignmentSegment_0:DesignParameters -> IfcAlignmentVerticalSegment + IfcAlignmentSegment_0:DesignParameters -> IfcAlignmentCantSegment + + IfcAlignmentHorizontalSegment:PredefinedType -> IfcAlignmentHorizontalSegmentTypeEnum + IfcAlignmentHorizontalSegment:PredefinedType[binding="HorizontalType"] + + IfcAlignmentVerticalSegment:PredefinedType -> IfcAlignmentVerticalSegmentTypeEnum + IfcAlignmentVerticalSegment:PredefinedType[binding="VerticalType"] + + IfcAlignmentCantSegment:PredefinedType -> IfcAlignmentCantSegmentTypeEnum + IfcAlignmentCantSegment:PredefinedType[binding="CantType"] +} +``` diff --git a/backend/apps/ifc_validation/checks/statistics/templates/Use_of_property_types.md b/backend/apps/ifc_validation/checks/statistics/templates/Use_of_property_types.md new file mode 100644 index 00000000..c9ad279e --- /dev/null +++ b/backend/apps/ifc_validation/checks/statistics/templates/Use_of_property_types.md @@ -0,0 +1,15 @@ +# Usage of property types + +> SCOPE IFC2X3+ + +This is a template to uncover statistics on usage of the various kinds of properties within the context of a property set. + +```text +concept { + IfcPropertySet:Name -> IfcLabel + IfcPropertySet:Name[binding="PropertySetName"] + + IfcPropertySet:HasProperties -> IfcProperty + IfcPropertySet:HasProperties[binding="PropertyType"] +} +``` diff --git a/backend/apps/ifc_validation/checks/statistics/tests/ColumnPSetsOfSets.ifc b/backend/apps/ifc_validation/checks/statistics/tests/ColumnPSetsOfSets.ifc new file mode 100644 index 00000000..26394a29 --- /dev/null +++ b/backend/apps/ifc_validation/checks/statistics/tests/ColumnPSetsOfSets.ifc @@ -0,0 +1,145 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [CoordinationView]','RevitIdentifiers [ContentGUID: a0df3484-2dab-42c5-b806-8c10d313bee0, VersionGUID: 658c1394-f3a4-43d1-9b3c-eee44a0cd67a, NumberOfSaves: 2]','CoordinateReference [CoordinateBase: Shared Coordinates]'),'2;1'); +FILE_NAME('Column_4x3.ifc','2025-03-12T13:53:30+00:00',(''),(''),'ODA SDAI 24.12','Autodesk Revit 25.4.0.32 (ENG) - IFC 25.4.0.32',''); +FILE_SCHEMA(('IFC4X3_ADD2')); +ENDSEC; +DATA; +#1=IFCORGANIZATION($,'Autodesk Revit 2025 (ENG)',$,$,$); +#2=IFCAPPLICATION(#1,'2025','Autodesk Revit 2025 (ENG)','Revit'); +#3=IFCCARTESIANPOINT((0.,0.,0.)); +#4=IFCCARTESIANPOINT((0.,0.)); +#5=IFCDIRECTION((1.,0.,0.)); +#6=IFCDIRECTION((-1.,0.,0.)); +#7=IFCDIRECTION((0.,1.,0.)); +#8=IFCDIRECTION((0.,-1.,0.)); +#9=IFCDIRECTION((0.,0.,1.)); +#10=IFCDIRECTION((0.,0.,-1.)); +#11=IFCDIRECTION((1.,0.)); +#12=IFCDIRECTION((-1.,0.)); +#13=IFCDIRECTION((0.,1.)); +#14=IFCDIRECTION((0.,-1.)); +#15=IFCPERSON($,'','sfriston',$,$,$,$,$); +#16=IFCORGANIZATION($,'','',$,$); +#17=IFCPERSONANDORGANIZATION(#15,#16,$); +#18=IFCOWNERHISTORY(#17,#2,$,.NOCHANGE.,$,$,$,1741776819); +#19=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); +#20=IFCAXIS2PLACEMENT3D(#3,$,$); +#21=IFCDIRECTION((6.12323399573677E-17,1.)); +#22=IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,0.01,#20,#21); +#23=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Axis','Model',*,*,*,*,#22,$,.GRAPH_VIEW.,$); +#24=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Model',*,*,*,*,#22,$,.MODEL_VIEW.,$); +#25=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Box','Model',*,*,*,*,#22,$,.MODEL_VIEW.,$); +#26=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('FootPrint','Model',*,*,*,*,#22,$,.MODEL_VIEW.,$); +#27=IFCPROJECT('2WtpI4BQj2nRW6Z10ix44G',#18,'Project Number',$,$,'Project Name','Project Status',(#22),#134); +#28=IFCCLASSIFICATION('CSI (Construction Specifications Institute)','1998',$,'Uniformat','UniFormat Classification','https://www.csiresources.org/standards/uniformat',$); +#29=IFCAXIS2PLACEMENT3D(#3,$,$); +#30=IFCLOCALPLACEMENT(#41,#29); +#31=IFCPOSTALADDRESS($,$,$,$,('Enter address here'),$,'London','London','','United Kingdom'); +#32=IFCBUILDING('2WtpI4BQj2nRW6Z10ix44H',#18,'',$,$,#30,$,'',.ELEMENT.,$,$,#31); +#33=IFCAXIS2PLACEMENT3D(#3,$,$); +#34=IFCLOCALPLACEMENT(#30,#33); +#35=IFCBUILDINGSTOREY('3Zu5Bv0LOHrPC10026FoQQ',#18,'Level 0',$,'Level:Circle Head - Project Datum',#34,$,'Level 0',.ELEMENT.,0.); +#36=IFCCARTESIANPOINT((0.,0.,4000.)); +#37=IFCAXIS2PLACEMENT3D(#36,$,$); +#38=IFCLOCALPLACEMENT(#30,#37); +#39=IFCBUILDINGSTOREY('15Z0v90RiHrPC20026FoKR',#18,'Level 1',$,'Level:Circle Head - Project Datum',#38,$,'Level 1',.ELEMENT.,4000.); +#40=IFCAXIS2PLACEMENT3D(#3,$,$); +#41=IFCLOCALPLACEMENT($,#40); +#42=IFCSITE('2WtpI4BQj2nRW6Z10ix44I',#18,'Default',$,$,#41,$,$,.ELEMENT.,(51,30,23,112487),(0,-7,-37,-956022),0.,$,$); +#43=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('Project Information'),$); +#44=IFCPROPERTYSET('2eqIbmOAscNen5$6huR8Xq',#18,'Pset_SiteCommon',$,(#43)); +#45=IFCPROPERTYSINGLEVALUE('IsExternal',$,IFCBOOLEAN(.F.),$); +#46=IFCPROPERTYSET('23aMNsN69_fPNNwy7k03QC',#18,'Pset_SpaceCommon',$,(#43,#45)); +#47=IFCRELDEFINESBYPROPERTIES('3Y58BrwSEMG4WuDkCGbgWd',#18,$,$,(#42),#44); +#48=IFCRELDEFINESBYPROPERTIES('0HzXjYMsQZ24nGDtNrqPeG',#18,$,$,(#42),#46); +#51=IFCAXIS2PLACEMENT3D(#3,$,$); +#53=IFCCARTESIANPOINT((0.,0.)); +#54=IFCAXIS2PLACEMENT2D(#53,#11); +#55=IFCISHAPEPROFILEDEF(.AREA.,'UC305x305x97',#54,305.3,307.9,9.90000000000002,15.4,15.2,$,$); +#56=IFCCARTESIANPOINT((-0.,0.,-2500.)); +#57=IFCAXIS2PLACEMENT3D(#56,#9,#6); +#58=IFCEXTRUDEDAREASOLID(#55,#57,#9,2500.); +#59=IFCSIUNIT(*,.PLANEANGLEUNIT.,$,.RADIAN.); +#60=IFCDIMENSIONALEXPONENTS(0,0,0,0,0,0,0); +#61=IFCMEASUREWITHUNIT(IFCPLANEANGLEMEASURE(0.0174532925199433),#59); +#62=IFCCONVERSIONBASEDUNIT(#60,.PLANEANGLEUNIT.,'DEGREE',#61); +#63=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); +#64=IFCCOLOURRGB($,0.968627450980392,0.968627450980392,0.968627450980392); +#65=IFCSURFACESTYLERENDERING(#64,0.,$,$,$,$,IFCNORMALISEDRATIOMEASURE(0.5),IFCSPECULAREXPONENT(128.),.NOTDEFINED.); +#66=IFCSURFACESTYLE('Metal - Steel 43-275',.BOTH.,(#65)); +#67=IFCSTYLEDITEM(#58,(#66),$); +#68=IFCSHAPEREPRESENTATION(#24,'Body','SweptSolid',(#58)); +#69=IFCAXIS2PLACEMENT3D(#3,$,$); +#70=IFCREPRESENTATIONMAP(#69,#68); +#71=IFCCOLUMNTYPE('1xkS0AgRTExwh4MNwOgZlD',#18,'UC-Universal Columns-Column:UC305x305x97',$,$,(#104),(#70),'12190',$,.COLUMN.); +#72=IFCMATERIAL('Metal - Steel 43-275',$,'Metal'); +#73=IFCCOLOURRGB($,0.,0.,0.); +#74=IFCDRAUGHTINGPREDEFINEDCURVEFONT('continuous'); +#75=IFCCURVESTYLE($,#74,$,#73,$); +#76=IFCFILLAREASTYLEHATCHING(#75,IFCPOSITIVELENGTHMEASURE(355.6),$,#4,45.); +#77=IFCCURVESTYLE($,#74,$,#73,$); +#78=IFCCARTESIANPOINT((0.,381.)); +#79=IFCFILLAREASTYLEHATCHING(#77,IFCPOSITIVELENGTHMEASURE(355.6),$,#78,45.); +#80=IFCFILLAREASTYLE('Steel',(#76,#79),$); +#81=IFCSTYLEDITEM($,(#66,#80),$); +#82=IFCSTYLEDREPRESENTATION(#22,'Style','Material and Cut Pattern',(#81)); +#83=IFCMATERIALDEFINITIONREPRESENTATION($,$,(#82),#72); +#84=IFCMATERIALPROFILE('UC305x305x97',$,#72,#55,$,$); +#85=IFCMATERIALPROFILESET('UC305x305x97',$,(#84),$); +#86=IFCCARTESIANTRANSFORMATIONOPERATOR3D($,$,#3,1.,$); +#87=IFCMAPPEDITEM(#70,#86); +#88=IFCSHAPEREPRESENTATION(#24,'Body','MappedRepresentation',(#87)); +#89=IFCPRODUCTDEFINITIONSHAPE($,$,(#88)); +#90=IFCCARTESIANPOINT((-384.581868533682,-142.731561117908,0.)); +#91=IFCAXIS2PLACEMENT3D(#90,$,$); +#92=IFCLOCALPLACEMENT(#34,#91); +#93=IFCCOLUMN('1xkS0AgRTExwh4MNwOgZlF',#18,'UC-Universal Columns-Column:UC305x305x97:323405',$,'UC-Universal Columns-Column:UC305x305x97',#92,#89,'323405',.COLUMN.); +#94=IFCMATERIALPROFILESETUSAGE(#85,$,$); +#95=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('UC305x305x97'),$); +#96=IFCPROPERTYSINGLEVALUE('LoadBearing',$,IFCBOOLEAN(.T.),$); +#97=IFCPROPERTYSET('2Kyssv$kPduAY5HpYkdCrw',#18,'Pset_ColumnCommon',$,(#45,#95,#96)); +#98=IFCPROPERTYSET('2E7Xcrf_DiKNFkQbRhLvWM',#18,'Pset_EnvironmentalImpactIndicators',$,(#95)); +#99=IFCPROPERTYSINGLEVALUE('Reference',$,IFCLABEL('UC305x305x97'),$); +#100=IFCPROPERTYSET('1sLpijh_wqTpS_F_2QKBAo',#18,'Pset_ReinforcementBarPitchOfColumn',$,(#99)); +#101=IFCRELDEFINESBYPROPERTIES('3_kOamSQVXh_EoGxsxage3',#18,$,$,(#93),#97); +#102=IFCRELDEFINESBYPROPERTIES('15Go3bkBxe46jug3ptPJu$',#18,$,$,(#93),#98); +#103=IFCRELDEFINESBYPROPERTIES('1pHUPkPxgww0bggyxncELu',#18,$,$,(#93),#100); +#104=IFCPROPERTYSET('0AQD0eY0wih1QOXNiuj$Qc',#18,'Pset_ColumnCommon',$,(#96)); +#105=IFCPROPERTYSINGLEVALUE('Reference',$,IFCIDENTIFIER('Circle Head - Project Datum'),$); +#106=IFCPROPERTYSINGLEVALUE('AboveGround',$,IFCLOGICAL(.F.),$); +#107=IFCPROPERTYSET('2QFFSloDKLdASEirlNrsWG',#18,'Pset_BuildingStoreyCommon',$,(#105,#106)); +#108=IFCPROPERTYSET('2khPkDg4bBoX9QFSHYxSac',#18,'Pset_SpaceCommon',$,(#45,#105)); +#109=IFCRELDEFINESBYPROPERTIES('24vWbHxUvnwTMhwrnGQFQP',#18,$,$,(#35),#107); +#110=IFCRELDEFINESBYPROPERTIES('2H1toi6agC_mYxUZT$TnC9',#18,$,$,(#35),#108); +#111=IFCRELCONTAINEDINSPATIALSTRUCTURE('3Zu5Bv0LOHrPC10066FoQQ',#18,$,$,(#93),#35); +#112=IFCPROPERTYSET('2y6jqut2mGsxPM5c1zUPr2',#18,'Pset_BuildingStoreyCommon',$,(#105,#106)); +#113=IFCPROPERTYSET('0gGGxtUVcimZNbYugu0sB_',#18,'Pset_SpaceCommon',$,(#45,#105)); +#114=IFCRELDEFINESBYPROPERTIES('03h9WtlPXy$ENv9AiS5UZC',#18,$,$,(#39),#112); +#115=IFCRELDEFINESBYPROPERTIES('2TN3LdVu1OLnLXbjiOXGUP',#18,$,$,(#39),#113); +#116=IFCRELAGGREGATES('2PcN$ItYEMyBNCqPOl6HsC',#18,$,$,#27,(#42)); +#117=IFCRELAGGREGATES('25zzFe0MOSV8bFW$lLhYMm',#18,$,$,#42,(#32)); +#118=IFCRELAGGREGATES('2$1LAsj$T3CPRLiPj39tWH',#18,$,$,#32,(#35,#39)); +#119=IFCPROPERTYSINGLEVALUE('NumberOfStoreys',$,IFCCOUNTMEASURE(1),$); +#120=IFCPROPERTYSINGLEVALUE('IsLandmarked',$,IFCLOGICAL(.F.),$); +#121=IFCPROPERTYSET('3U5dlTKgNpdXvGG_h1UB1e',#18,'Pset_BuildingCommon',$,(#43,#119,#120)); +#122=IFCPROPERTYSET('0qtiToowyEn_jqyPw36bkp',#18,'Pset_BuildingElementProxyCommon',$,(#43,#45)); +#123=IFCPROPERTYSET('2Xe4e_z6nyyQBfJ9yOJXYv',#18,'Pset_BuildingStoreyCommon',$,(#43,#106)); +#124=IFCPROPERTYSET('07mSmCWmizAULxEjtjvEjg',#18,'Pset_BuildingSystemCommon',$,(#43)); +#125=IFCPROPERTYSET('1GCWRxA7BU8FaVCiUcq6nY',#18,'Pset_SpaceCommon',$,(#43,#45)); +#126=IFCRELDEFINESBYPROPERTIES('3xYjKs1Tlm5uUJZprkLSk8',#18,$,$,(#32),#121); +#127=IFCRELDEFINESBYPROPERTIES('1Wd03xjOnSPTtTqMwLi2Lg',#18,$,$,(#32),#122); +#128=IFCRELDEFINESBYPROPERTIES('3j$dTd3Sjcf3$gHpCvuLB7',#18,$,$,(#32),#123); +#129=IFCRELDEFINESBYPROPERTIES('09MeCIXvuVClu2P8lIqAgG',#18,$,$,(#32),#124); +#130=IFCRELDEFINESBYPROPERTIES('2HoHooQ7$oNZlOP_3ex_aK',#18,$,$,(#32),#125); +#131=IFCRELASSOCIATESMATERIAL('2n2HQdKqzYK7QnaOEEelx_',#18,$,$,(#71),#85); +#132=IFCRELDEFINESBYTYPE('11G_CSisKD8eybmW1Vx0bN',#18,$,$,(#93),#71); +#133=IFCPRESENTATIONLAYERASSIGNMENT('S-280-M_COLUMN',$,(#68,#88),$); +#134=IFCUNITASSIGNMENT((#19,#62,#63)); +#135=IFCPROPERTYSINGLEVALUE('Label',$,IFCLABEL('Pset 1'),$); +#136=IFCPROPERTYSET('2n2HQdKqzYK7QnaOEEelxy',$,'PSet_1',$,(#135)); +#137=IFCPROPERTYSINGLEVALUE('Label',$,IFCLABEL('Pset 2'),$); +#138=IFCPROPERTYSET('2n2HQdKqzYK7QnaOEEelxz',$,'PSet_2',$,(#137)); +#139=IFCRELDEFINESBYPROPERTIES('2n2HQdKqzYK7QnaOEEelzz',$,$,$,(#93),IFCPROPERTYSETDEFINITIONSET((#136,#138))); +ENDSEC; +END-ISO-10303-21; diff --git a/backend/apps/ifc_validation/checks/statistics/tests/pass-IfcCurveStyle-ifc2x3.ifc b/backend/apps/ifc_validation/checks/statistics/tests/pass-IfcCurveStyle-ifc2x3.ifc new file mode 100644 index 00000000..139ee406 --- /dev/null +++ b/backend/apps/ifc_validation/checks/statistics/tests/pass-IfcCurveStyle-ifc2x3.ifc @@ -0,0 +1,10 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition [CoordinationView]'),'2;1'); +FILE_NAME('','2022-12-12T15:43:30',(''),(''),'','',''); +FILE_SCHEMA(('IFC2X3')); +ENDSEC; +DATA; +#1=IFCCURVESTYLE($,$,IFCDESCRIPTIVEMEASURE('by layer'),$); +ENDSEC; +END-ISO-10303-21; diff --git a/backend/apps/ifc_validation/checks/statistics/tests/test_template_statistics.py b/backend/apps/ifc_validation/checks/statistics/tests/test_template_statistics.py new file mode 100644 index 00000000..ad363903 --- /dev/null +++ b/backend/apps/ifc_validation/checks/statistics/tests/test_template_statistics.py @@ -0,0 +1,58 @@ +from collections import Counter +from pathlib import Path + +from apps.ifc_validation.checks.statistics.apply_mvd import ( + available_template_names, + extract_template_statistics, +) + + +FIXTURES = Path(__file__).parent + + +def test_column_property_set_statistics(): + results = extract_template_statistics(FIXTURES / "ColumnPSetsOfSets.ifc") + + assert len(results) == 17 + assert {result["template"] for result in results} == {"Use_of_property_types.md"} + assert {result["focus_ifc_type"] for result in results} == {"IfcPropertySet"} + assert { + result["graph"]["PropertyType"] for result in results + } == {"IfcPropertySingleValue"} + assert Counter( + result["graph"]["PropertySetName"] for result in results + ) == Counter({ + "Pset_SpaceCommon": 4, + "Pset_BuildingStoreyCommon": 3, + "Pset_ColumnCommon": 2, + "Pset_SiteCommon": 1, + "Pset_EnvironmentalImpactIndicators": 1, + "Pset_ReinforcementBarPitchOfColumn": 1, + "Pset_BuildingCommon": 1, + "Pset_BuildingElementProxyCommon": 1, + "Pset_BuildingSystemCommon": 1, + "PSet_1": 1, + "PSet_2": 1, + }) + + +def test_ifc2x3_curve_style_statistics(): + results = extract_template_statistics(FIXTURES / "pass-IfcCurveStyle-ifc2x3.ifc") + + assert results == [{ + "template": "Usage_of_bylayer_IfcCurveStyle.md", + "focus_step_id": 1, + "focus_ifc_type": "IfcCurveStyle", + "graph": {"IfcDescriptiveMeasure": "IfcDescriptiveMeasure"}, + }] + + +def test_extraction_can_be_limited_to_missing_template_names(): + assert "Use_of_property_types.md" in available_template_names() + + results = extract_template_statistics( + FIXTURES / "ColumnPSetsOfSets.ifc", + template_names=("Usage_of_transition_curves_geometry.md",), + ) + + assert results == [] diff --git a/backend/apps/ifc_validation/management/commands/populate_statistics.py b/backend/apps/ifc_validation/management/commands/populate_statistics.py new file mode 100644 index 00000000..1110a55c --- /dev/null +++ b/backend/apps/ifc_validation/management/commands/populate_statistics.py @@ -0,0 +1,125 @@ +from django.core.management.base import BaseCommand, CommandError +from django.db.models import Count, Exists, OuterRef, Q + +from apps.ifc_validation.checks.statistics.apply_mvd import available_template_names +from apps.ifc_validation.tasks.statistics_tasks import ( + missing_template_names, + populate_entity_count_histogram, + populate_pset_count_histogram, + populate_template_statistics, +) +from apps.ifc_validation_models.models import ( + EntityCountHistogram, + Model, + PsetCountHistogram, +) + + +class Command(BaseCommand): + help = "Populate all missing statistics for up to N eligible models." + + statistic_tasks = ( + ("entity histogram", populate_entity_count_histogram), + ("property-set histogram", populate_pset_count_histogram), + ("template statistics", populate_template_statistics), + ) + + def add_arguments(self, parser): + parser.add_argument( + "count", + type=int, + help="Maximum number of models to process.", + ) + + def handle(self, *args, **options): + count = options["count"] + if count < 1: + raise CommandError("count must be greater than zero") + + template_names = available_template_names() + completed_entity_histograms = EntityCountHistogram.objects.filter( + model_id=OuterRef("pk"), + count=EntityCountHistogram.COMPLETION_MARKER_COUNT, + ) + completed_pset_histograms = PsetCountHistogram.objects.filter( + model_id=OuterRef("pk"), + count=PsetCountHistogram.COMPLETION_MARKER_COUNT, + ) + annotations = { + "has_entity_histogram": Exists(completed_entity_histograms), + "has_pset_histogram": Exists(completed_pset_histograms), + } + incomplete = ( + Q(has_entity_histogram=False) + | Q(has_pset_histogram=False) + ) + if template_names: + annotations["completed_template_count"] = Count( + "template_statistics__template_name", + filter=Q( + template_statistics__graph__isnull=True, + template_statistics__template_name__in=template_names, + ), + distinct=True, + ) + incomplete |= Q(completed_template_count__lt=len(template_names)) + + models = list( + Model.objects.annotate( + **annotations, + ) + .filter(incomplete) + .exclude(file="") + .distinct() + .order_by("-pk") + [:count] + ) + + failures = [] + totals = {label: 0 for label, _ in self.statistic_tasks} + for model in models: + pending_tasks = [] + if not model.has_entity_histogram: + pending_tasks.append(( + "entity histogram", + populate_entity_count_histogram, + (model.pk,), + )) + if not model.has_pset_histogram: + pending_tasks.append(( + "property-set histogram", + populate_pset_count_histogram, + (model.pk,), + )) + missing_templates = missing_template_names(model, template_names) + if missing_templates: + pending_tasks.append(( + "template statistics", + populate_template_statistics, + (model.pk, missing_templates), + )) + + for label, task, task_arguments in pending_tasks: + try: + totals[label] += task.run(*task_arguments) + except Exception as error: + failures.append((model.pk, label)) + self.stderr.write( + self.style.ERROR(f"Model {model.pk}, {label}: {error}") + ) + + summary = ", ".join( + f"{value} {label} result(s)" for label, value in totals.items() + ) + self.stdout.write(self.style.SUCCESS( + f"Processed {len(models)} model(s); {summary}." + )) + + if failures: + formatted_failures = ", ".join( + f"{model_id} ({label})" for model_id, label in failures + ) + raise CommandError( + f"Failed to populate {len(failures)} statistic task(s): " + f"{formatted_failures}" + ) diff --git a/backend/apps/ifc_validation/statistics_query.py b/backend/apps/ifc_validation/statistics_query.py new file mode 100644 index 00000000..3478e5c3 --- /dev/null +++ b/backend/apps/ifc_validation/statistics_query.py @@ -0,0 +1,756 @@ +import functools +import re +from collections import defaultdict +from dataclasses import dataclass +from decimal import Decimal + +import ifcopenshell +from django import forms +from django.db.models import ( + BooleanField, + Case, + CharField, + Count, + F, + FloatField, + Q, + Value, + When, +) +from django.db.models.functions import Cast +from django.db.models.fields.json import KeyTextTransform +from django.template.defaultfilters import floatformat + +from apps.ifc_validation.checks.statistics.apply_mvd import TEMPLATES_DIR +from apps.ifc_validation_models.models import ( + EntityCountHistogram, + Model, + PsetCountHistogram, + TemplateStatistic, +) + +from apps.ifc_validation.statistics_query_concepts import ( + CONCEPT, + CONCEPTS, + EXPRESSION_OPERATOR, + EXPRESSION_OPERATORS, + FUNCTION, + FUNCTIONS, + OPERAND, + OPERANDS, + OPERATION, + OPERATIONS, + ORDERING, + ORDERINGS, + QUERY_OPERATOR, + QUERY_OPERATORS, + SOURCE, + SOURCES, + QueryFilter, + StatisticsExpression, + StatisticsQuery, + choices, +) +from apps.ifc_validation.statistics_query_examples import EXAMPLES + + +class StatisticsSourceForm(forms.Form): + source = forms.ChoiceField(choices=choices(SOURCES)) + + +class StatisticsQueryClauseForm(forms.Form): + OPERATION_CHOICES = choices(OPERATIONS) + operation = forms.ChoiceField(choices=OPERATION_CHOICES) + target = forms.ChoiceField(choices=[ + *((f"filter:{concept.name}", concept.label) for concept in CONCEPTS if "filter" in concept.acts_in), + *((f"group:{concept.name}", concept.label) for concept in CONCEPTS if "group" in concept.acts_in), + *((f"order:{ordering.name}", ordering.label) for ordering in ORDERINGS), + ], required=False) + operator = forms.ChoiceField(choices=choices(QUERY_OPERATORS), required=False) + value = forms.CharField(max_length=1024, required=False) + expression_function = forms.ChoiceField( + choices=choices(FUNCTIONS), + required=False, + widget=forms.Select(attrs={"aria-label": "Function"}), + ) + operand_a = forms.ChoiceField( + choices=[("", "๐‘Ž"), *choices(OPERANDS)], + required=False, + widget=forms.Select(attrs={"aria-label": "Operand A"}), + ) + expression_operator = forms.ChoiceField( + choices=choices(EXPRESSION_OPERATORS), + required=False, + widget=forms.Select(attrs={"aria-label": "Operator"}), + ) + operand_b = forms.ChoiceField( + choices=[("", "๐‘"), *choices(OPERANDS)], + required=False, + widget=forms.Select(attrs={"aria-label": "Operand B"}), + ) + + def clean(self): + cleaned = super().clean() + if cleaned.get("DELETE"): + return cleaned + + operation = cleaned.get("operation") + target = cleaned.get("target") + operator = cleaned.get("operator") + value = cleaned.get("value", "").strip() + if not operation: + return cleaned + + if operation == "limit": + if value.casefold() == "all": + cleaned["resolved_value"] = None + return cleaned + try: + limit = int(value) + if not 1 <= limit <= 1000: + raise ValueError + except ValueError: + self.add_error("value", "Limit must be between 1 and 1000, or all.") + else: + cleaned["resolved_value"] = limit + return cleaned + + if operation == "expression": + operand_a = cleaned.get("operand_a") + expression_operator = cleaned.get("expression_operator") + operand_b = cleaned.get("operand_b") + if not operand_a: + self.add_error("operand_a", "Select operand A.") + if expression_operator and not operand_b: + self.add_error("operand_b", "Select operand B.") + if operand_b and not expression_operator: + self.add_error("expression_operator", "Select an operator.") + cleaned["resolved_value"] = StatisticsExpression( + cleaned.get("expression_function"), operand_a, + expression_operator, operand_b, + ) + return cleaned + + expected_prefix = f"{operation}:" + if not target or not target.startswith(expected_prefix): + self.add_error("target", "Select a value for this operation.") + return cleaned + resolved_value = target.removeprefix(expected_prefix) + cleaned["resolved_value"] = resolved_value + if operation == "group" and resolved_value == "graph_value": + if not value: + self.add_error("value", "Enter a JSON graph path.") + elif not re.fullmatch( + r"[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*", + value, + ): + self.add_error("value", "Enter a dotted JSON path using field names.") + else: + cleaned["resolved_value"] = f"graph_value:{value}" + return cleaned + if operation != "filter": + return cleaned + + field = resolved_value + concept = CONCEPT[field] + if not operator: + self.add_error("operator", "Select an operator.") + elif operator not in concept.operators: + self.add_error("operator", "This operator is not available for the selected field.") + if not value: + self.add_error("value", "Enter a value.") + return cleaned + + try: + typed_value = concept.parse(value) + if field == "model" and not Model.objects.filter(pk=typed_value).exists(): + self.add_error("value", "No model with this ID exists.") + except ValueError: + self.add_error("value", f"Invalid value for {concept.label}.") + else: + cleaned["value"] = value + cleaned["typed_value"] = typed_value + return cleaned + +StatisticsQueryClauseFormSet = forms.formset_factory( + StatisticsQueryClauseForm, + extra=0, + can_delete=True, + max_num=50, + validate_max=True, +) + + +def build_statistics_expression(selection): + """Compatibility helper for callers that have structured expression form data.""" + return StatisticsExpression(**selection).source + + +def model_histogram_query(source, model_id): + if source not in {"entity", "pset"}: + raise ValueError(f"Unsupported model histogram source {source!r}.") + return StatisticsQuery( + source, + ("entity", "pset_name", "standardized") if source == "pset" else ("entity",), + StatisticsExpression("sum"), + limit=None, + filters=(QueryFilter("model", "eq", model_id),), + ) + + +def query_form_clauses(query): + clauses = [ + {"operation": "filter", "target": f"filter:{item.concept}", + "operator": item.operator, "value": CONCEPT[item.concept].serialize(item.value)} + for item in query.filters + ] + for group in query.groups: + concept, _, graph_path = group.partition(":") + clause = {"operation": "group", "target": f"group:{concept}"} + if graph_path: + clause["value"] = graph_path + clauses.append(clause) + clauses.extend(( + {"operation": "expression", "expression_function": query.expression.function, + "operand_a": query.expression.operand_a, + "expression_operator": query.expression.operator, + "operand_b": query.expression.operand_b}, + {"operation": "order", "target": f"order:{query.ordering}"}, + {"operation": "limit", "value": query.limit if query.limit is not None else "all"}, + )) + return clauses + + +def bind_statistics_query_form_data(query): + clauses = query_form_clauses(query) + data = { + "source": query.source, + "clauses-TOTAL_FORMS": len(clauses), + "clauses-INITIAL_FORMS": 0, + "clauses-MIN_NUM_FORMS": 0, + "clauses-MAX_NUM_FORMS": 50, + } + for index, clause in enumerate(clauses): + for field, value in clause.items(): + data[f"clauses-{index}-{field}"] = value + return data + + +def build_statistics_specification(source, clause_formset): + clauses = [ + form.cleaned_data + for form in clause_formset.forms + if form.cleaned_data and not form.cleaned_data.get("DELETE") + ] + operations = { + operation: [clause for clause in clauses if clause["operation"] == operation] + for operation in OPERATION + } + if not operations["group"]: + raise ValueError("The query requires at least one group clause.") + if len(operations["expression"]) != 1: + raise ValueError("The query requires exactly one expression clause.") + for operation in ("order", "limit"): + if len(operations[operation]) > 1: + raise ValueError(f"The query accepts at most one {operation} clause.") + + return StatisticsQuery( + source=source, + groups=tuple( + clause["resolved_value"] + for clause in operations["group"] + ), + expression=operations["expression"][0]["resolved_value"], + ordering=( + operations["order"][0]["resolved_value"] + if operations["order"] else "descending" + ), + limit=operations["limit"][0]["resolved_value"] if operations["limit"] else None, + filters=tuple( + QueryFilter(clause["resolved_value"], clause["operator"], clause["typed_value"]) + for clause in operations["filter"] + ), + ) + + +def _example_clause_context(clause): + operation = clause["operation"] + if operation == "expression": + expression = StatisticsExpression( + clause["expression_function"], clause["operand_a"], + clause["expression_operator"], clause["operand_b"], + ) + return { + "operation": OPERATION[operation].label, + "expression": { + "function": FUNCTION[expression.function].label, + "function_active": bool(expression.function), + "operand_a": OPERAND[expression.operand_a].label, + "operator": EXPRESSION_OPERATOR[expression.operator].label, + "operator_active": bool(expression.operator), + "operand_b": OPERAND[expression.operand_b].label if expression.operand_b else "๐‘", + "operand_b_active": bool(expression.operand_b), + }, + "form": clause, + } + if operation == "limit": + return {"operation": OPERATION[operation].label, "selection": str(clause["value"]), + "operator": "", "value": "", "form": clause} + _, target = clause["target"].split(":", 1) + selection = ORDERING[target].label if operation == "order" else CONCEPT[target].label + return {"operation": OPERATION[operation].label, "selection": selection, + "operator": QUERY_OPERATOR[clause["operator"]].label if operation == "filter" else "", + "value": clause.get("value", ""), "form": clause} + + +def _example_context(title, query): + clauses = query_form_clauses(query) + return {"title": title, "source": SOURCE[query.source].label, + "clauses": [_example_clause_context(clause) for clause in clauses], + "form_data": {"source": query.source, "clauses": clauses}} + + +STATISTICS_QUERY_EXAMPLES = [_example_context(*example) for example in EXAMPLES] + + +def statistics_query_ui_context(): + schemas = list( + Model.objects.exclude(schema__isnull=True).exclude(schema="") + .values_list("schema", flat=True).distinct().order_by("schema") + ) + entity_names = set() + for schema in schemas: + try: + entity_names.update(EntityCountHistogram.entity_names(schema)) + except RuntimeError: + continue + template_names = { + path.name for path in TEMPLATES_DIR.glob("*.md") + } | set( + TemplateStatistic.objects.filter(graph__isnull=False) + .values_list("template_name", flat=True) + .distinct() + ) + + return { + "statistics_query_examples": STATISTICS_QUERY_EXAMPLES, + "clause_target_choices": { + "filter": { + source: [ + {"value": f"filter:{concept.name}", "label": concept.label} + for concept in CONCEPTS if concept.supports("filter", source) + ] + for source in SOURCE + }, + "group": { + source: [ + {"value": f"group:{concept.name}", "label": concept.label} + for concept in CONCEPTS if concept.supports("group", source) + ] + for source in SOURCE + }, + "expression": [], + "order": [ + {"value": f"order:{value}", "label": label} + for value, label in choices(ORDERINGS) + ], + "limit": [], + }, + "filter_operator_choices": { + f"filter:{field}": [ + {"value": operator, "label": QUERY_OPERATOR[operator].label} + for operator in concept.operators + ] + for field, concept in CONCEPT.items() if "filter" in concept.acts_in + }, + "filter_suggestions": { + "models": [ + (str(model.pk), f"#{model.pk} - {model.file_name}") + for model in Model.objects.only("id", "file_name").order_by("-created") + ], + "schemas": [(schema, schema) for schema in schemas], + "entities": [(name, name) for name in sorted(entity_names)], + "templates": [ + (name, name.removesuffix(".md").replace("_", " ")) + for name in sorted(template_names) + ], + }, + } + + +@dataclass +class StatisticsQueryResult: + columns: list[str] + rows: list[list] + sql: str + + @property + def display_rows(self): + return [ + [format_statistics_value(value) for value in row] + for row in self.rows + ] + + +def format_statistics_value(value): + if isinstance(value, (float, Decimal)): + return floatformat(value, "-2") + return value + + +def format_sql(sql): + try: + import sqlparse + except ImportError: + return sql + return sqlparse.format( + sql, + reindent=True, + keyword_case="upper", + identifier_case=None, + ) + + +class StatisticsQueryBuilder: + """Translate the canonical query into reusable Django query patterns.""" + + def __init__(self, specification): + self.spec = specification + self.source = SOURCE[specification.source] + self.filters = specification.filters + self.groups = specification.groups + self.schema = self.resolve_schema() + + def resolve_schema(self): + model_ids = { + clause.value + for clause in self.filters + if clause.concept == "model" and clause.operator == "eq" + } + schemas = { + clause.value + for clause in self.filters + if clause.concept == "schema" and clause.operator == "eq" + } + if len(model_ids) > 1 or len(schemas) > 1: + raise ValueError("Entity resolution requires one model or one exact schema filter.") + + model_schema = None + if model_ids: + model = Model.objects.filter(pk=next(iter(model_ids))).only("schema").first() + if model is None: + raise ValueError("The selected model no longer exists.") + model_schema = model.schema + schema = next(iter(schemas), None) + if model_schema and schema and model_schema != schema: + raise ValueError("The model and schema filters refer to different schemas.") + return model_schema or schema + + @staticmethod + @functools.lru_cache(maxsize=None) + def subtype_indices(schema, base_type): + schema_definition = ifcopenshell.schema_by_name(schema) + indices = [] + for index, entity_name in enumerate(EntityCountHistogram.entity_names(schema)): + declaration = schema_definition.declaration_by_name(entity_name) + while declaration: + declaration = declaration.supertype() + if declaration and declaration.name() == base_type: + indices.append(index) + break + return tuple(indices) + + def base_queryset(self): + query = self.source.queryset() + for clause in self.filters: + query = self.apply_filter(query, clause) + return query + + def apply_filter(self, query, clause): + concept = CONCEPT[clause.concept] + if not concept.supports("filter", self.source.name): + raise ValueError( + f"Filter {concept.name!r} is not available for {self.source.name!r}.", + ) + + if concept.name == "entity": + if not self.schema: + raise ValueError("Entity filters require one model or one exact schema filter.") + return self.apply_entity_filter(query, clause.operator, clause.value) + if concept.name == "is_vendor": + return self.apply_vendor_filter( + query, clause.operator, clause.value, "model__", + ) + return self.apply_lookup_filter( + query, concept.lookup, clause.operator, clause.value, + ) + + @classmethod + def apply_vendor_filter(cls, query, operator, value, model_prefix): + vendor_status = Case( + When( + Q(**{ + f"{model_prefix}uploaded_by__useradditionalinfo__is_vendor": True, + }) + | Q(**{ + f"{model_prefix}uploaded_by__useradditionalinfo__is_vendor_self_declared": True, + }), + then=Value(True), + ), + default=Value(False), + output_field=BooleanField(), + ) + query = query.annotate(statistics_is_vendor=vendor_status) + return cls.apply_lookup_filter( + query, "statistics_is_vendor", operator, value, + ) + + def apply_entity_filter(self, query, operator, entity_name): + if operator in {"subtype_of", "not_subtype_of"}: + indices = self.subtype_indices(self.schema, entity_name) + if self.source.name == "template": + values = [ + EntityCountHistogram.string_from_index(self.schema, index) + for index in indices + ] + lookup = "focus_instance__ifc_type__in" + else: + values = indices + lookup = "entity_index__in" + method = "exclude" if operator == "not_subtype_of" else "filter" + return getattr(query, method)(**{lookup: values}) + + if operator not in {"eq", "ne"}: + raise ValueError(f"Unsupported entity operator {operator!r}.") + value = ( + entity_name + if self.source.name == "template" + else EntityCountHistogram.index_from_string(self.schema, entity_name) + ) + lookup = "focus_instance__ifc_type" if self.source.name == "template" else "entity_index" + method = "exclude" if operator == "ne" else "filter" + return getattr(query, method)(**{lookup: value}) + + @staticmethod + def apply_lookup_filter(query, lookup, operator, value): + try: + operation = QUERY_OPERATOR[operator] + except KeyError as error: + raise ValueError(f"Unsupported operator {operator!r}.") from error + if operation.special: + raise ValueError(f"Operator {operator!r} requires an entity concept.") + method = query.exclude if operation.negated else query.filter + return method(**{f"{lookup}{operation.suffix}": value}) + + def computed_model_count(self): + models = Model.objects.all() + for clause in self.filters: + if clause.concept == "is_vendor": + models = self.apply_vendor_filter( + models, clause.operator, clause.value, "", + ) + elif clause.concept in {"model", "schema", "is_staff"}: + lookup = { + "model": "pk", + "schema": "schema", + "is_staff": "uploaded_by__is_staff", + }[clause.concept] + models = self.apply_lookup_filter( + models, lookup, clause.operator, clause.value, + ) + if self.source.name == "entity": + models = models.filter( + histogram_entries__count=EntityCountHistogram.COMPLETION_MARKER_COUNT, + ) + elif self.source.name == "pset": + models = models.filter( + pset_count_entries__count=PsetCountHistogram.COMPLETION_MARKER_COUNT, + ) + else: + completion_markers = TemplateStatistic.objects.filter( + graph__isnull=True, + ) + for clause in self.filters: + if clause.concept != "template": + continue + completion_markers = self.apply_lookup_filter( + completion_markers, "template_name", clause.operator, clause.value, + ) + models = models.filter( + pk__in=completion_markers.values("model_id"), + ) + return models.distinct().count() + + def grouped_queryset(self, base): + fields = [] + labels = [] + for group_index, group in enumerate(self.groups): + concept_name, _, argument = group.partition(":") + try: + concept = CONCEPT[concept_name] + except KeyError as error: + raise ValueError(f"Unsupported grouping {group!r}.") from error + if not concept.supports("group", self.source.name): + raise ValueError( + f"Unsupported grouping {group!r} for {self.source.name!r}.", + ) + if concept_name == "entity": + if self.source.name == "template": + group_fields, group_labels = ["focus_instance__ifc_type"], ["Entity"] + else: + group_fields = ["model__schema", "entity_index"] + group_labels = ["Schema", "Entity"] + elif concept_name == "graph_value": + graph_path = argument + alias = f"graph_value_{group_index}" + lookup = f"graph__{graph_path.replace('.', '__')}" + base = base.annotate(**{ + alias: KeyTextTransform.from_lookup(lookup), + }) + group_fields, group_labels = [alias], [f"Graph: {graph_path}"] + elif concept_name == "proxy": + if not self.schema: + raise ValueError( + "Proxy grouping requires entity counts and one schema or model.", + ) + proxy_indices = set( + self.subtype_indices(self.schema, "IfcBuildingElementProxy"), + ) + proxy_indices.add( + EntityCountHistogram.index_from_string( + self.schema, + "IfcBuildingElementProxy", + ) + ) + base = base.annotate( + proxy_group=Case( + When(entity_index__in=proxy_indices, then=Value("Proxy")), + default=Value("Other element subtypes"), + output_field=CharField(), + ) + ) + group_fields, group_labels = ["proxy_group"], ["Category"] + else: + group_fields = list(concept.group_fields) + group_labels = list(concept.result_labels) + + for field, label in zip(group_fields, group_labels): + if field not in fields: + fields.append(field) + labels.append(label) + return base, fields, labels + + def count_expression(self): + return self.source.count_expression() + + def display_key(self, fields, values): + displayed = list(values) + schema = ( + values[fields.index("model__schema")] + if "model__schema" in fields else self.schema + ) + for index, field in enumerate(fields): + if field == "entity_index": + displayed[index] = ( + EntityCountHistogram.string_from_index(schema, values[index]) + if values[index] is not None else "Property definitions" + ) + elif field == "is_standardized": + displayed[index] = "Standard" if values[index] else "Custom" + elif field == "pset_name" and not values[index]: + displayed[index] = "(unnamed)" + elif field == "model__produced_by_id" and values[index] is None: + displayed[index] = "(unknown)" + elif field == "model__produced_by__name" and not values[index]: + displayed[index] = "(unknown)" + elif field == "model__produced_by__version" and not values[index]: + displayed[index] = "(unspecified)" + return displayed + + def execute(self): + base = self.base_queryset() + grouped_base, fields, labels = self.grouped_queryset(base) + expression = self.spec.expression + expression.validate() + limit = self.spec.limit + descending = self.spec.ordering == "descending" + count_expression = self.count_expression() + + if expression.is_average: + return self.average_expression_result( + grouped_base, + fields, + labels, + count_expression, + expression, + ) + + total_count = base.aggregate(total=count_expression)["total"] or 0 + computed_models = self.computed_model_count() + query = grouped_base.values(*fields).annotate( + source_count=count_expression, + source_models=Count("model_id", distinct=True), + ) + if expression.source == "count": + query = query.annotate(value=F("source_count")) + elif expression.source == "models": + query = query.annotate(value=F("source_models")) + else: + expression_values = { + "count": Cast(F("source_count"), FloatField()), + "models": Cast(F("source_models"), FloatField()), + "computed_models": Value(float(computed_models or 1)), + "total_count": Value(float(total_count or 1)), + } + query = query.annotate(value=expression.compile(expression_values)) + + query = query.order_by("-value" if descending else "value") + if limit is not None: + query = query[:limit] + raw_rows = list(query.values_list(*fields, "value")) + rows = [self.display_key(fields, row[:-1]) + [row[-1]] for row in raw_rows] + return StatisticsQueryResult( + labels + [expression.source], + rows, + format_sql(str(query.query)), + ) + + def average_expression_result(self, base, fields, labels, count_expression, expression): + per_model = ( + base.values("model_id", *fields) + .annotate(group_count=count_expression) + .order_by() + ) + records = list(per_model.values_list("model_id", *fields, "group_count")) + totals = defaultdict(float) + grouped = defaultdict(float) + for record in records: + model_id = record[0] + key = tuple(record[1:-1]) + count = record[-1] + totals[model_id] += count + grouped[(model_id, key)] += count + + denominator = self.computed_model_count() + total_count = sum(totals.values()) + averages = defaultdict(float) + for (model_id, key), count in grouped.items(): + averages[key] += expression.evaluate({ + "count": count, + "models": 1, + "computed_models": denominator, + "total_count": total_count, + "model_total_count": totals[model_id], + }) + rows = [ + self.display_key(fields, key) + [value / (denominator or 1)] + for key, value in averages.items() + ] + rows.sort(key=lambda row: row[-1], reverse=self.spec.ordering == "descending") + if self.spec.limit is not None: + rows = rows[:self.spec.limit] + return StatisticsQueryResult( + labels + [expression.source], + rows, + format_sql(str(per_model.query)), + ) diff --git a/backend/apps/ifc_validation/statistics_query_concepts.py b/backend/apps/ifc_validation/statistics_query_concepts.py new file mode 100644 index 00000000..d9d4ca4d --- /dev/null +++ b/backend/apps/ifc_validation/statistics_query_concepts.py @@ -0,0 +1,365 @@ +""" +This module offers a runtime builder mechanism for composing queries that assess +statistics on IFC models or sets of models. + +These tasks are background tasks and every data model offers a way to record a +'completion marker' (multiple in case of the templates) so that computation of +statistics happens outside of the user-facing flow and can be scheduled at times +of low CPU usage. + +At its basis are three database tables, populated by corresponding tasks, that +can be queried. + +== entity + +An aggregated count of entities (e.g. IfcWall) found in the model, with +materialized inheritance (e.g. IfcRoot). + +| Field | Meaning | +|----------------|---------| +| model | The IFC model to which the histogram row belongs. | +| entity_index | Index into the alphabetically sorted entity names for the model's IFC schema. | +| is_supertype | False for concrete instances, true for counts materialized from subtype instances, and null for the completion marker. | +| count | Number of matching instances; zero identifies the completion marker. | + +== pset + +An aggregated count of property- and quantity-set names (e.g. Pset_WallCommon) as: + +- Independent property-set definitions, irrespective of association to elements. +- Property-set definitions as they are associated with elements and element types. + +In case of schema-predefined property sets the entity name is used. + +| Field | Meaning | +|-----------------|---------| +| model | The IFC model to which the histogram row belongs. | +| entity_index | Null for independent definitions; otherwise the schema entity index of the associated object type. | +| pset_name | Property- or quantity-set name; an empty string represents an unnamed set. | +| is_standardized | Whether the name occurs in the schema's property-set definitions. | +| count | Number of definitions or associated objects; zero identifies the completion marker. | + +== template + +In various cases we are interested in usage patterns such as the parent or basis +curves of geometrical entities. This requires concept templates, identical in +form to those in the IFC 4.3+ specification, that are applied as graph queries +to the model. + +| Field | Meaning | +|----------------|---------| +| model | The IFC model to which the statistic row belongs. | +| template_name | Filename of the Markdown concept template that produced the row. | +| focus_instance | Model instance matched as the focus of the template; null on completion markers. | +| graph | JSON object containing matched graph bindings; null identifies completion for this model and template. | + +== query builder concepts + +| Name | Label | Acts in | Valid sources | Filter operators | +|----------------|-------------------------------|------------------|------------------------|------------------| +| model | Model ID | filter, group | entity, pset, template | eq, ne | +| schema | IFC schema | filter, group | entity, pset, template | eq, ne, contains, not_contains | +| entity | Entity | filter, group | entity, pset, template | eq, ne, subtype_of, not_subtype_of | +| count | Count | filter | entity, pset | eq, ne, gt, gte, lt, lte | +| entity_kind | Entity row type | filter | entity | eq, ne | +| pset_name | Property-set name | filter, group | pset | eq, ne, contains, not_contains | +| pset_scope | Property-set scope | filter | pset | eq, ne | +| standardized | Standardized / custom | filter, group | pset | eq, ne | +| is_vendor | Uploader is vendor | filter | entity, pset, template | eq, ne | +| is_staff | Uploader is staff | filter | entity, pset, template | eq, ne | +| proxy | Proxy / other element subtype | group | entity | โ€” | +| template | Template | filter, group | template | eq, ne, contains, not_contains | +| authoring_tool | Authoring tool | group | template | โ€” | +| graph_value | Template graph value | group | template | โ€” | + +""" + +import operator +from dataclasses import dataclass + +from django.db.models import Count, ExpressionWrapper, FloatField, Sum, Value + +from apps.ifc_validation_models.models import ( + EntityCountHistogram, + PsetCountHistogram, + TemplateStatistic, +) + + +ALL_SOURCES = frozenset({"entity", "pset", "template"}) + + +@dataclass(frozen=True) +class NamedChoice: + name: str + label: str + + +@dataclass(frozen=True) +class QueryOperator(NamedChoice): + suffix: str = "" + negated: bool = False + special: bool = False + + +@dataclass(frozen=True) +class ExpressionOperator(NamedChoice): + symbol: str = "" + function: object = None + + +@dataclass(frozen=True) +class Concept(NamedChoice): + """One vocabulary entry used by forms, validation and query construction.""" + + acts_in: frozenset + valid_sources: frozenset = ALL_SOURCES + operators: tuple = () + lookup: str | None = None + values: tuple = () + value_type: str = "text" + group_fields: tuple = () + result_labels: tuple = () + + def supports(self, operation, source): + return operation in self.acts_in and source in self.valid_sources + + def parse(self, value): + if self.values: + try: + return dict(self.values)[value.casefold()] + except KeyError as error: + raise ValueError from error + if self.value_type == "non_negative_integer": + parsed = int(value) + if parsed < 0: + raise ValueError + return parsed + return value + + def serialize(self, value): + for name, parsed in self.values: + if parsed == value: + return name + return str(value) + + +@dataclass(frozen=True) +class StatisticsSource(NamedChoice): + model: type + conditions: tuple + count_field: str | None + + def queryset(self): + return self.model.objects.filter(**dict(self.conditions)) + + def count_expression(self): + return Sum(self.count_field) if self.count_field else Count("pk") + + +@dataclass(frozen=True) +class QueryFilter: + concept: str + operator: str + value: object + + +@dataclass(frozen=True) +class StatisticsExpression: + function: str = "" + operand_a: str = "count" + operator: str = "" + operand_b: str = "" + + NAMES = frozenset({ + "count", "computed_models", "total_count", "model_total_count", "1", "100", + }) + + def validate(self): + if self.function not in FUNCTION or self.operand_a not in OPERAND: + raise ValueError("Unsupported expression function or operand.") + if self.operator not in EXPRESSION_OPERATOR: + raise ValueError("Unsupported expression operator.") + if bool(self.operator) != bool(self.operand_b): + raise ValueError("An expression operator and operand B must be used together.") + if self.function == "average": + if {self.operand_a, self.operand_b} - {""} <= { + "count", "model_total_count", "1", "100", + }: + return + raise ValueError("Unsupported AVG expression.") + if self.function == "sum": + if not self.operator and self.operand_a == "count": + return + raise ValueError("Unsupported SUM expression.") + if self.function == "count_distinct": + if not self.operator and self.operand_a == "model": + return + raise ValueError("Unsupported COUNT DISTINCT expression.") + operands = {self.operand_a, self.operand_b} - {""} + if not operands <= self.NAMES or "model_total_count" in operands: + raise ValueError("Unsupported expression operand.") + + @property + def source(self): + self.validate() + expression = self.operand_a + if self.operator: + operation = EXPRESSION_OPERATOR[self.operator] + expression = f"{expression} {operation.symbol} {self.operand_b}" + if not self.function: + return expression + if self.function == "average": + return f"avg({expression})" + return "count" if self.function == "sum" else "models" + + @property + def is_average(self): + return self.function == "average" + + @property + def names(self): + self.validate() + return {name for name in (self.operand_a, self.operand_b) if name in self.NAMES} + + def _operand(self, name, values, orm=False): + if name in {"1", "100"}: + number = float(name) + return Value(number) if orm else number + return values[name] + + def compile(self, values): + self.validate() + expression = self._operand(self.operand_a, values, orm=True) + if self.operator: + expression = EXPRESSION_OPERATOR[self.operator].function( + expression, self._operand(self.operand_b, values, orm=True), + ) + return ExpressionWrapper(expression, output_field=FloatField()) + + def evaluate(self, values): + self.validate() + left = self._operand(self.operand_a, values) + if not self.operator: + return left + try: + return EXPRESSION_OPERATOR[self.operator].function( + left, self._operand(self.operand_b, values), + ) + except ZeroDivisionError: + return 0 + + +@dataclass(frozen=True) +class StatisticsQuery: + source: str + groups: tuple + expression: StatisticsExpression = StatisticsExpression() + ordering: str = "descending" + limit: int | None = 10 + filters: tuple = () + + +def _index(entries): + return {entry.name: entry for entry in entries} + + +OPERATIONS = ( + NamedChoice("filter", "Filter"), NamedChoice("group", "Group by"), + NamedChoice("expression", "Expression"), NamedChoice("order", "Order by"), + NamedChoice("limit", "Limit"), +) +ORDERINGS = ( + NamedChoice("descending", "Descending"), NamedChoice("ascending", "Ascending"), +) +QUERY_OPERATORS = ( + QueryOperator("eq", "is"), QueryOperator("ne", "is not", negated=True), + QueryOperator("gt", ">", "__gt"), QueryOperator("gte", ">=", "__gte"), + QueryOperator("lt", "<", "__lt"), QueryOperator("lte", "<=", "__lte"), + QueryOperator("contains", "contains", "__icontains"), + QueryOperator("not_contains", "does not contain", "__icontains", True), + QueryOperator("subtype_of", "is a subtype of", special=True), + QueryOperator("not_subtype_of", "is not a subtype of", negated=True, special=True), +) +FUNCTIONS = ( + NamedChoice("", "๐‘“"), NamedChoice("sum", "SUM"), + NamedChoice("average", "AVG"), NamedChoice("count_distinct", "COUNT DISTINCT"), +) +OPERANDS = ( + NamedChoice("count", "count"), NamedChoice("model", "model"), + NamedChoice("computed_models", "computed models"), + NamedChoice("total_count", "total count"), + NamedChoice("model_total_count", "model total count"), + NamedChoice("1", "1"), NamedChoice("100", "100"), +) +EXPRESSION_OPERATORS = ( + ExpressionOperator("", "op"), + ExpressionOperator("add", "+", "+", operator.add), + ExpressionOperator("subtract", "โˆ’", "-", operator.sub), + ExpressionOperator("multiply", "ร—", "*", operator.mul), + ExpressionOperator("divide", "รท", "/", operator.truediv), +) + +_FILTER = frozenset({"filter"}) +_GROUP = frozenset({"group"}) +_BOTH = _FILTER | _GROUP +_ENTITY_PSET = frozenset({"entity", "pset"}) +_BOOLEAN_VALUES = (("true", True), ("false", False)) +CONCEPTS = ( + Concept("model", "Model ID", _BOTH, operators=("eq", "ne"), lookup="model_id", + value_type="non_negative_integer", group_fields=("model_id", "model__file_name"), + result_labels=("Model ID", "Model")), + Concept("schema", "IFC schema", _BOTH, operators=("eq", "ne", "contains", "not_contains"), + lookup="model__schema", group_fields=("model__schema",), result_labels=("Schema",)), + Concept("entity", "Entity", _BOTH, operators=("eq", "ne", "subtype_of", "not_subtype_of")), + Concept("count", "Count", _FILTER, _ENTITY_PSET, + ("eq", "ne", "gt", "gte", "lt", "lte"), "count", + value_type="non_negative_integer"), + Concept("entity_kind", "Entity row type", _FILTER, frozenset({"entity"}), + ("eq", "ne"), "is_supertype", (("concrete", False), ("inherited", True))), + Concept("pset_name", "Property-set name", _BOTH, frozenset({"pset"}), + ("eq", "ne", "contains", "not_contains"), "pset_name", + group_fields=("pset_name",), result_labels=("Property set",)), + Concept("pset_scope", "Property-set scope", _FILTER, frozenset({"pset"}), + ("eq", "ne"), "entity_index__isnull", + (("definitions", True), ("associations", False))), + Concept("standardized", "Standardized / custom", _BOTH, frozenset({"pset"}), + ("eq", "ne"), "is_standardized", + (("standard", True), ("true", True), ("custom", False), ("false", False)), + group_fields=("is_standardized",), result_labels=("Standardized",)), + Concept("is_vendor", "Uploader is vendor", _FILTER, + operators=("eq", "ne"), values=_BOOLEAN_VALUES), + Concept("is_staff", "Uploader is staff", _FILTER, + operators=("eq", "ne"), lookup="model__uploaded_by__is_staff", + values=_BOOLEAN_VALUES), + Concept("proxy", "Proxy / other element subtype", _GROUP, frozenset({"entity"})), + Concept("template", "Template", _BOTH, frozenset({"template"}), + ("eq", "ne", "contains", "not_contains"), "template_name", + group_fields=("template_name",), result_labels=("Template",)), + Concept("authoring_tool", "Authoring tool", _GROUP, frozenset({"template"}), + group_fields=("model__produced_by_id", "model__produced_by__name", + "model__produced_by__version"), + result_labels=("Authoring tool ID", "Authoring tool", "Version")), + Concept("graph_value", "Template graph value", _GROUP, frozenset({"template"})), +) +SOURCES = ( + StatisticsSource("entity", "Entity counts", EntityCountHistogram, + (("count__gt", 0), ("is_supertype__isnull", False)), "count"), + StatisticsSource("pset", "Property-set counts", PsetCountHistogram, + (("count__gt", 0),), "count"), + StatisticsSource("template", "Template statistics", TemplateStatistic, + (("graph__isnull", False),), None), +) + +OPERATION = _index(OPERATIONS) +ORDERING = _index(ORDERINGS) +QUERY_OPERATOR = _index(QUERY_OPERATORS) +FUNCTION = _index(FUNCTIONS) +OPERAND = _index(OPERANDS) +EXPRESSION_OPERATOR = _index(EXPRESSION_OPERATORS) +CONCEPT = _index(CONCEPTS) +SOURCE = _index(SOURCES) + + +def choices(entries): + return [(entry.name, entry.label) for entry in entries] diff --git a/backend/apps/ifc_validation/statistics_query_examples.py b/backend/apps/ifc_validation/statistics_query_examples.py new file mode 100644 index 00000000..96fc69d9 --- /dev/null +++ b/backend/apps/ifc_validation/statistics_query_examples.py @@ -0,0 +1,60 @@ +from apps.ifc_validation.statistics_query_concepts import ( + QueryFilter, + StatisticsExpression, + StatisticsQuery, +) + + +def example(title, source, groups, filters=(), expression=StatisticsExpression(), limit=10): + return title, StatisticsQuery(source, groups, expression, limit=limit, filters=filters) + + +EXAMPLES = ( + example("Top 10 element subtypes used in one file", "entity", ("entity",), + (QueryFilter("model", "eq", 123), QueryFilter("entity", "subtype_of", "IfcElement"), + QueryFilter("entity_kind", "eq", False)), StatisticsExpression("sum")), + example("Average top 10 element subtypes used in files of an IFC version", "entity", ("entity",), + (QueryFilter("schema", "eq", "IFC4"), QueryFilter("entity", "subtype_of", "IfcElement"), + QueryFilter("entity_kind", "eq", False)), StatisticsExpression("average")), + example("Number of files of an IFC version containing an entity", "entity", ("entity",), + (QueryFilter("schema", "eq", "IFC4"), QueryFilter("entity", "eq", "IfcWall"), + QueryFilter("entity_kind", "eq", False), QueryFilter("count", "gt", 0)), + StatisticsExpression("count_distinct", "model")), + example("Top 10 property sets used in one file", "pset", ("pset_name",), + (QueryFilter("model", "eq", 123), QueryFilter("pset_scope", "eq", True)), + StatisticsExpression("sum")), + example("Average top 10 property sets used in files of an IFC version", "pset", ("pset_name",), + (QueryFilter("schema", "eq", "IFC4"), QueryFilter("pset_scope", "eq", True)), + StatisticsExpression("average")), + example("Ratio of standard versus custom property sets in one file", "pset", ("standardized",), + (QueryFilter("model", "eq", 123), QueryFilter("pset_scope", "eq", True)), + StatisticsExpression(operator="divide", operand_b="total_count")), + example("Average ratio of standard versus custom property sets by IFC version", "pset", + ("standardized",), + (QueryFilter("schema", "eq", "IFC4"), QueryFilter("pset_scope", "eq", True)), + StatisticsExpression("average", operator="divide", operand_b="model_total_count")), + example("Ratio of proxy versus other element subtypes in one file", "entity", ("proxy",), + (QueryFilter("model", "eq", 123), QueryFilter("entity", "subtype_of", "IfcElement"), + QueryFilter("entity_kind", "eq", False)), + StatisticsExpression(operator="divide", operand_b="total_count")), + example("Average proxy ratio in files of an IFC version", "entity", ("proxy",), + (QueryFilter("schema", "eq", "IFC4"), QueryFilter("entity", "subtype_of", "IfcElement"), + QueryFilter("entity_kind", "eq", False)), + StatisticsExpression("average", operator="divide", operand_b="model_total_count")), + example("Property type counts grouped by AuthoringTool", "template", + ("authoring_tool", "graph_value:PropertyType"), + (QueryFilter("template", "eq", "Use_of_property_types.md"),), + StatisticsExpression("sum"), None), + example("Property type counts for a single model", "template", ("graph_value:PropertyType",), + (QueryFilter("model", "eq", 123), + QueryFilter("template", "eq", "Use_of_property_types.md")), + StatisticsExpression("sum"), None), + example("Basis counts grouped by AuthoringTool", "template", + ("authoring_tool", "graph_value:ParentCurve"), + (QueryFilter("template", "eq", "Usage_of_transition_curves_geometry.md"),), + StatisticsExpression("sum"), None), + example("Basis type counts for a single model", "template", ("graph_value:ParentCurve",), + (QueryFilter("model", "eq", 123), + QueryFilter("template", "eq", "Usage_of_transition_curves_geometry.md")), + StatisticsExpression("sum"), None), +) diff --git a/backend/apps/ifc_validation/tasks/__init__.py b/backend/apps/ifc_validation/tasks/__init__.py index a3c97a8e..411f89fe 100644 --- a/backend/apps/ifc_validation/tasks/__init__.py +++ b/backend/apps/ifc_validation/tasks/__init__.py @@ -14,7 +14,13 @@ bsdd_validation_subtask, industry_practices_subtask, instance_completion_subtask, - magic_clamav_subtask + magic_clamav_subtask, +) +from .statistics_tasks import ( + populate_entity_count_histogram, + populate_pset_count_histogram, + populate_template_statistics, + schedule_model_statistic_tasks, ) __all__ = [ @@ -29,5 +35,9 @@ "normative_rules_ip_validation_subtask", "industry_practices_subtask", "instance_completion_subtask", - "magic_clamav_subtask" -] \ No newline at end of file + "magic_clamav_subtask", + "populate_entity_count_histogram", + "populate_pset_count_histogram", + "populate_template_statistics", + "schedule_model_statistic_tasks", +] diff --git a/backend/apps/ifc_validation/tasks/statistics_tasks.py b/backend/apps/ifc_validation/tasks/statistics_tasks.py new file mode 100644 index 00000000..ffadf55e --- /dev/null +++ b/backend/apps/ifc_validation/tasks/statistics_tasks.py @@ -0,0 +1,496 @@ +import csv +import functools +import json +import subprocess +import sys +import textwrap +from pathlib import Path + +import psutil +from celery import group, shared_task +from celery.utils.log import get_task_logger +from django.conf import settings +from django.db.models import Count, Q + +from core.utils import log_execution + +from apps.ifc_validation.checks.statistics.apply_mvd import available_template_names +from apps.ifc_validation_models.models import ( + EntityCountHistogram, + Model, + ModelInstance, + PsetCountHistogram, + TemplateStatistic, +) + +from .utils import get_absolute_file_path + +logger = get_task_logger(__name__) + +PSET_DEFINITIONS_ROOT = ( + Path(__file__).resolve().parent.parent + / "checks" + / "ifc_gherkin_rules" + / "features" + / "resources" +) + + +_IFC_LOADER_SCRIPT = textwrap.dedent( + """ + import gzip + import json + import sys + + import ifcopenshell + + def open_ifc(file_path): + if file_path.lower().endswith(".gz"): + with gzip.open(file_path, "rt", encoding="utf-8") as compressed_file: + return ifcopenshell.file.from_string(compressed_file.read()) + return ifcopenshell.open(file_path) + """ +) + + +_ENTITY_HISTOGRAM_SCRIPT = _IFC_LOADER_SCRIPT + textwrap.dedent( + """ + import functools + from collections import Counter + + file_path = json.load(sys.stdin) + ifc_file = open_ifc(file_path) + schema_identifier = ifc_file.schema_identifier + schema = ifcopenshell.schema_by_name(schema_identifier) + + @functools.cache + def supertypes(entity_name): + declaration = schema.declaration_by_name(entity_name) + result = [] + while declaration: + result.append(declaration.name()) + declaration = declaration.supertype() + return tuple(result) + + counts = Counter() + for instance in ifc_file: + for index, entity_name in enumerate(supertypes(instance.is_a())): + counts[(entity_name, index > 0)] += 1 + + json.dump( + { + "schema_identifier": schema_identifier, + "entries": [ + [entity_name, is_supertype, count] + for (entity_name, is_supertype), count in sorted(counts.items()) + ], + }, + sys.stdout, + ) + """ +) + + +_PSET_HISTOGRAM_SCRIPT = _IFC_LOADER_SCRIPT + textwrap.dedent( + """ + from collections import Counter + + file_path = json.load(sys.stdin) + ifc_file = open_ifc(file_path) + counts = Counter() + + def property_definitions(value): + if value.is_a() == "IfcPropertySetDefinitionSet": + yield from value[0] + else: + yield value + + def pset_name(pset): + is_predefined = False + try: + # ifc4 and higher + is_predefined = pset.is_a("IfcPreDefinedPropertySet") + except: + # ifc2x3 + is_predefined = not pset.is_a("IfcPropertySet") + return pset.is_a() if is_predefined else (pset.Name or "") + + for pset in ifc_file.by_type("IfcPropertySetDefinition"): + counts[(None, pset_name(pset))] += 1 + + for type_object in ifc_file.by_type("IfcTypeObject"): + for pset in type_object.HasPropertySets or (): + counts[(type_object.is_a(), pset_name(pset))] += 1 + + for relationship in ifc_file.by_type("IfcRelDefinesByProperties"): + for pset in property_definitions(relationship.RelatingPropertyDefinition): + for related_object in relationship.RelatedObjects: + counts[(related_object.is_a(), pset_name(pset))] += 1 + + json.dump( + { + "schema_identifier": ifc_file.schema_identifier, + "entries": [ + [entity_name, pset_name, count] + for (entity_name, pset_name), count in sorted( + counts.items(), + key=lambda item: ((item[0][0] or ""), item[0][1]), + ) + ], + }, + sys.stdout, + ) + """ +) + + +_TEMPLATE_STATISTICS_SCRIPT = _IFC_LOADER_SCRIPT + textwrap.dedent( + """ + from apps.ifc_validation.checks.statistics.apply_mvd import ( + extract_template_statistics, + ) + + file_path, template_names = json.load(sys.stdin) + json.dump( + extract_template_statistics( + open_ifc(file_path), + template_names=template_names, + ), + sys.stdout, + ) + """ +) + + +def _run_ifc_statistics_subprocess(script, payload): + process = subprocess.run( + [sys.executable, "-u", "-c", script], + input=json.dumps(payload), + capture_output=True, + text=True, + ) + if process.returncode != 0: + logger.error(process.stderr) + raise RuntimeError( + f"IFC statistics subprocess exited with code {process.returncode}", + ) + try: + return json.loads(process.stdout) + except json.JSONDecodeError as error: + raise RuntimeError("IFC statistics subprocess returned invalid JSON") from error + + +def extract_entity_histogram_in_subprocess(file_path): + return _run_ifc_statistics_subprocess(_ENTITY_HISTOGRAM_SCRIPT, file_path) + + +def extract_pset_histogram_in_subprocess(file_path): + return _run_ifc_statistics_subprocess(_PSET_HISTOGRAM_SCRIPT, file_path) + + +def extract_template_statistics_in_subprocess(file_path, template_names): + return _run_ifc_statistics_subprocess( + _TEMPLATE_STATISTICS_SCRIPT, + [file_path, list(template_names)], + ) + + +def model_statistics_file_path(model): + file_name = str(model.file) + try: + return get_absolute_file_path(file_name) + except FileNotFoundError: + archive_name = ( + file_name if file_name.lower().endswith(".gz") + else f"{file_name}.gz" + ) + if archive_name != file_name: + try: + return get_absolute_file_path(archive_name) + except FileNotFoundError: + pass + + logger.warning( + "Skipping statistics for model %s: neither %s nor its gzip archive exists", + model.pk, + file_name, + ) + return None + + +def pset_resource_schema(schema_identifier): + normalized = schema_identifier.upper() + if normalized.startswith("IFC4X3"): + return "IFC4X3" + if normalized.startswith("IFC4"): + return "IFC4" + if normalized.startswith("IFC2X3"): + return "IFC2X3" + raise ValueError( + f"No property-set definitions are available for {schema_identifier!r}", + ) + + +@functools.cache +def standardized_pset_names(schema_identifier): + csv_path = ( + PSET_DEFINITIONS_ROOT + / pset_resource_schema(schema_identifier) + / "pset_definitions.csv" + ) + with csv_path.open(encoding="utf-8-sig", newline="") as csv_file: + return frozenset( + row["Name"] + for row in csv.DictReader(csv_file) + if row.get("Name") + ) + + +def missing_template_names(model, template_names=None): + if template_names is None: + template_names = available_template_names() + completed = set( + model.template_statistics.filter( + graph__isnull=True, + template_name__in=template_names, + ).values_list("template_name", flat=True) + ) + return tuple(name for name in template_names if name not in completed) + + +def _complete_failed_statistics(model, statistic_name, marker_queryset, markers): + logger.exception( + "Failed to populate %s for model %s; recording completion marker(s)", + statistic_name, + model.pk, + ) + markers = tuple(markers) + marker_queryset.delete() + if markers: + type(markers[0]).objects.bulk_create(markers) + + +@shared_task +@log_execution +def populate_entity_count_histogram(model_id): + model = Model.objects.get(pk=model_id) + file_path = model_statistics_file_path(model) + if file_path is None: + return 0 + try: + extracted = extract_entity_histogram_in_subprocess(file_path) + schema_identifier = extracted["schema_identifier"] + entries = [ + EntityCountHistogram( + model=model, + entity_index=EntityCountHistogram.index_from_string( + schema_identifier, + entity_name, + ), + count=count, + is_supertype=is_supertype, + ) + for entity_name, is_supertype, count in extracted["entries"] + ] + except Exception: + _complete_failed_statistics( + model, + "entity-count histogram", + model.histogram_entries.filter( + count=EntityCountHistogram.COMPLETION_MARKER_COUNT, + ), + [EntityCountHistogram.completion_marker(model)], + ) + return 0 + + model.histogram_entries.all().delete() + EntityCountHistogram.objects.bulk_create([ + *entries, + EntityCountHistogram.completion_marker(model), + ]) + return len(entries) + + +@shared_task +@log_execution +def populate_pset_count_histogram(model_id): + model = Model.objects.get(pk=model_id) + file_path = model_statistics_file_path(model) + if file_path is None: + return 0 + try: + extracted = extract_pset_histogram_in_subprocess(file_path) + schema_identifier = extracted["schema_identifier"] + standardized_names = standardized_pset_names(schema_identifier) + entries = [ + PsetCountHistogram( + model=model, + entity_index=( + EntityCountHistogram.index_from_string( + schema_identifier, + entity_name, + ) + if entity_name is not None else None + ), + pset_name=pset_name, + is_standardized=pset_name in standardized_names, + count=count, + ) + for entity_name, pset_name, count in extracted["entries"] + ] + except Exception: + _complete_failed_statistics( + model, + "property-set histogram", + model.pset_count_entries.filter( + count=PsetCountHistogram.COMPLETION_MARKER_COUNT, + ), + [PsetCountHistogram.completion_marker(model)], + ) + return 0 + + model.pset_count_entries.all().delete() + PsetCountHistogram.objects.bulk_create([ + *entries, + PsetCountHistogram.completion_marker(model), + ]) + return len(entries) + + +@shared_task +@log_execution +def populate_template_statistics(model_id, template_names): + model = Model.objects.get(pk=model_id) + template_names = tuple(template_names) + file_path = model_statistics_file_path(model) + if file_path is None: + return 0 + try: + extracted = extract_template_statistics_in_subprocess( + file_path, + template_names, + ) + except Exception: + _complete_failed_statistics( + model, + "template statistics", + model.template_statistics.filter( + template_name__in=template_names, + graph__isnull=True, + ), + ( + TemplateStatistic.completion_marker(model, template_name) + for template_name in template_names + ), + ) + return 0 + + model.template_statistics.filter( + template_name__in=template_names, + ).delete() + matches = [] + for result in extracted: + focus_instance, _ = ModelInstance.objects.get_or_create( + model=model, + stepfile_id=result["focus_step_id"], + defaults={"ifc_type": result["focus_ifc_type"]}, + ) + matches.append(TemplateStatistic( + model=model, + template_name=result["template"], + focus_instance=focus_instance, + graph=result["graph"], + )) + + TemplateStatistic.objects.bulk_create(matches) + TemplateStatistic.objects.bulk_create([ + TemplateStatistic.completion_marker(model, template_name) + for template_name in template_names + ]) + return len(matches) + + +@shared_task +@log_execution +def schedule_model_statistic_tasks(batch_size=100, cpu_threshold=50): + if batch_size < 1: + raise ValueError("batch_size must be greater than zero") + if not 0 <= cpu_threshold <= 100: + raise ValueError("cpu_threshold must be between 0 and 100") + + cpu_percent = psutil.cpu_percent(interval=1.0) + if cpu_percent >= cpu_threshold: + logger.info( + "Skipping model statistics: CPU usage %.1f%% is at or above %.1f%%", + cpu_percent, + cpu_threshold, + ) + return 0 + + retained_models = Model.objects.filter( + Q(request__isnull=True) | Q(request__file_removed__isnull=True), + size__lte=settings.MAX_FILE_SIZE_IN_MB * 1024 * 1024, + status_syntax=Model.Status.VALID, + ).exclude(file="") + + entity_model_ids = list( + retained_models + .exclude( + histogram_entries__count=EntityCountHistogram.COMPLETION_MARKER_COUNT, + ) + .distinct() + .order_by("pk") + .values_list("pk", flat=True)[:batch_size] + ) + + pset_model_ids = list( + retained_models + .exclude( + pset_count_entries__count=PsetCountHistogram.COMPLETION_MARKER_COUNT, + ) + .distinct() + .order_by("pk") + .values_list("pk", flat=True)[:batch_size] + ) + + template_names = available_template_names() + if template_names: + template_models = list( + retained_models + .annotate( + completed_template_count=Count( + "template_statistics__template_name", + filter=Q( + template_statistics__graph__isnull=True, + template_statistics__template_name__in=template_names, + ), + distinct=True, + ), + ) + .filter(completed_template_count__lt=len(template_names)) + .order_by("pk")[:batch_size] + ) + else: + template_models = [] + + tasks = [ + *(populate_entity_count_histogram.s(model_id) for model_id in entity_model_ids), + *(populate_pset_count_histogram.s(model_id) for model_id in pset_model_ids), + *( + populate_template_statistics.s( + model.pk, + missing_template_names(model, template_names), + ) + for model in template_models + ), + ] + if not tasks: + return 0 + + group(tasks).apply_async() + logger.info( + "Submitted %d model statistic task(s) at %.1f%% CPU usage", + len(tasks), + cpu_percent, + ) + return len(tasks) diff --git a/backend/apps/ifc_validation/templates/admin/ifc_validation_models/app_index.html b/backend/apps/ifc_validation/templates/admin/ifc_validation_models/app_index.html index a21c408e..45afcbe2 100644 --- a/backend/apps/ifc_validation/templates/admin/ifc_validation_models/app_index.html +++ b/backend/apps/ifc_validation/templates/admin/ifc_validation_models/app_index.html @@ -22,6 +22,11 @@

Metrics & Statistics

+

+ + Model statistics query builder + +

+ + +{% endblock %} diff --git a/backend/apps/ifc_validation/tests_statistics_query.py b/backend/apps/ifc_validation/tests_statistics_query.py new file mode 100644 index 00000000..d09a4567 --- /dev/null +++ b/backend/apps/ifc_validation/tests_statistics_query.py @@ -0,0 +1,1643 @@ +import gzip +from decimal import Decimal +from io import StringIO +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from django.conf import settings +from django.contrib import admin as django_admin +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.db import IntegrityError, transaction +from django.test import SimpleTestCase, TestCase +from django.urls import NoReverseMatch, reverse +from django.utils import timezone + +from apps.ifc_validation.checks.statistics.apply_mvd import available_template_names +from apps.ifc_validation.statistics_query import ( + CONCEPTS, + SOURCES, + QueryFilter, + StatisticsExpression, + StatisticsQuery, + StatisticsQueryClauseForm, + StatisticsQueryBuilder, + build_statistics_expression, + format_sql, + format_statistics_value, + statistics_query_ui_context, +) +from apps.ifc_validation.tasks.statistics_tasks import ( + extract_entity_histogram_in_subprocess, + extract_pset_histogram_in_subprocess, + extract_template_statistics_in_subprocess, + populate_entity_count_histogram, + populate_pset_count_histogram, + populate_template_statistics, + pset_resource_schema, + schedule_model_statistic_tasks, + standardized_pset_names, +) +from apps.ifc_validation_models.models import ( + AuthoringTool, + EntityCountHistogram, + Model, + ModelInstance, + PsetCountHistogram, + TemplateStatistic, + ValidationRequest, +) + + +class StatisticsValueTests(SimpleTestCase): + def test_celery_beat_uses_the_renamed_statistics_task_module(self): + schedule = settings.CELERY_BEAT_SCHEDULE[ + "schedule-model-statistic-tasks-every-15min" + ] + + assert schedule["task"] == ( + "apps.ifc_validation.tasks.statistics_tasks." + "schedule_model_statistic_tasks" + ) + assert schedule_model_statistic_tasks.name == schedule["task"] + + def test_clause_operations_use_expression_and_keep_source_separate(self): + operations = dict(StatisticsQueryClauseForm.OPERATION_CHOICES) + form = StatisticsQueryClauseForm() + + assert operations["expression"] == "Expression" + assert "express" not in operations + assert "select" not in operations + assert "source" not in operations + assert dict(form.fields["expression_function"].choices)[""] == "๐‘“" + assert dict(form.fields["operand_a"].choices)[""] == "๐‘Ž" + assert dict(form.fields["expression_operator"].choices)[""] == "op" + assert dict(form.fields["operand_b"].choices)[""] == "๐‘" + + def test_structured_expressions_compile_to_internal_syntax(self): + assert build_statistics_expression({ + "function": "", + "operand_a": "count", + "operator": "", + "operand_b": "", + }) == "count" + assert build_statistics_expression({ + "function": "average", + "operand_a": "count", + "operator": "", + "operand_b": "", + }) == "avg(count)" + assert build_statistics_expression({ + "function": "", + "operand_a": "count", + "operator": "divide", + "operand_b": "computed_models", + }) == "count / computed_models" + assert build_statistics_expression({ + "function": "average", + "operand_a": "count", + "operator": "divide", + "operand_b": "model_total_count", + }) == "avg(count / model_total_count)" + assert build_statistics_expression({ + "function": "count_distinct", + "operand_a": "model", + "operator": "", + "operand_b": "", + }) == "models" + assert build_statistics_expression({ + "function": "sum", + "operand_a": "count", + "operator": "", + "operand_b": "", + }) == "count" + + def test_invalid_function_expression_is_left_to_the_backend(self): + with self.assertRaisesRegex(ValueError, "Unsupported SUM expression"): + build_statistics_expression({ + "function": "sum", + "operand_a": "model", + "operator": "", + "operand_b": "", + }) + + def test_expression_operator_and_operand_b_are_an_optional_pair(self): + single_operand = StatisticsQueryClauseForm(data={ + "operation": "expression", + "operand_a": "count", + }) + missing_operator = StatisticsQueryClauseForm(data={ + "operation": "expression", + "operand_a": "count", + "operand_b": "total_count", + }) + missing_operand_b = StatisticsQueryClauseForm(data={ + "operation": "expression", + "operand_a": "count", + "expression_operator": "divide", + }) + + assert single_operand.is_valid() + assert not missing_operator.is_valid() + assert "expression_operator" in missing_operator.errors + assert not missing_operand_b.is_valid() + assert "operand_b" in missing_operand_b.errors + + def test_average_rejects_operands_unavailable_per_model(self): + with self.assertRaisesRegex(ValueError, "Unsupported AVG expression"): + build_statistics_expression({ + "function": "average", + "operand_a": "count", + "operator": "divide", + "operand_b": "computed_models", + }) + + def test_template_graph_group_accepts_a_safe_dotted_json_path(self): + form = StatisticsQueryClauseForm(data={ + "operation": "group", + "target": "group:graph_value", + "value": "Nested.PropertyType", + }) + + assert form.is_valid(), form.errors + assert form.cleaned_data["resolved_value"] == ( + "graph_value:Nested.PropertyType" + ) + + def test_template_graph_group_rejects_an_invalid_json_path(self): + form = StatisticsQueryClauseForm(data={ + "operation": "group", + "target": "group:graph_value", + "value": "PropertyType') OR TRUE --", + }) + + assert not form.is_valid() + assert "value" in form.errors + + def test_dimension_values_are_not_treated_as_numbers(self): + assert format_statistics_value("IFC4") == "IFC4" + assert format_statistics_value("IfcWall") == "IfcWall" + + def test_numeric_values_are_compactly_formatted(self): + assert format_statistics_value(575) == 575 + assert format_statistics_value(75.0) == "75" + assert format_statistics_value(Decimal("12.345")) == "12.35" + + def test_structured_expression_evaluates_arithmetic_and_rejects_unknown_names(self): + expression = StatisticsExpression( + "average", "count", "divide", "model_total_count", + ) + + assert expression.is_average + assert expression.names == {"count", "model_total_count"} + assert expression.evaluate({"count": 2, "model_total_count": 8}) == .25 + with self.assertRaisesRegex(ValueError, "Unsupported expression"): + StatisticsExpression(operand_a="__import__('os')").validate() + with self.assertRaisesRegex(ValueError, "Unsupported expression"): + StatisticsExpression( + operand_a="count", operator="divide", operand_b="model_total_count", + ).validate() + + def test_sql_uses_whitelist_style_formatting(self): + sql = format_sql("select a from example where a = 1 order by a") + + assert sql.startswith("SELECT a") + assert "\nFROM example" in sql + assert "\nWHERE a = 1" in sql + assert "\nORDER BY a" in sql + + +class StatisticsSubprocessTests(SimpleTestCase): + statistics_fixtures = ( + Path(__file__).parent + / "checks" + / "statistics" + / "tests" + ) + + def test_entity_and_pset_histograms_are_extracted_in_subprocesses(self): + file_path = self.statistics_fixtures / "ColumnPSetsOfSets.ifc" + + entities = extract_entity_histogram_in_subprocess(str(file_path)) + psets = extract_pset_histogram_in_subprocess(str(file_path)) + + assert entities["schema_identifier"] == "IFC4X3_ADD2" + assert any( + entity_name == "IfcPropertySet" and not is_supertype and count > 0 + for entity_name, is_supertype, count in entities["entries"] + ) + pset_counts = { + (entity_name, pset_name): count + for entity_name, pset_name, count in psets["entries"] + } + assert psets["schema_identifier"] == "IFC4X3_ADD2" + assert pset_counts[(None, "Pset_ColumnCommon")] == 2 + + def test_template_statistics_are_extracted_in_a_subprocess(self): + results = extract_template_statistics_in_subprocess( + str(self.statistics_fixtures / "ColumnPSetsOfSets.ifc"), + ("Use_of_property_types.md",), + ) + + assert len(results) == 17 + assert {result["template"] for result in results} == { + "Use_of_property_types.md", + } + + def test_all_statistics_are_extracted_from_a_retained_gzip_file(self): + source = self.statistics_fixtures / "ColumnPSetsOfSets.ifc" + with TemporaryDirectory() as directory: + archive = Path(directory) / "ColumnPSetsOfSets.ifc.gz" + with gzip.open(archive, "wb") as compressed_file: + compressed_file.write(source.read_bytes()) + + entities = extract_entity_histogram_in_subprocess(str(archive)) + psets = extract_pset_histogram_in_subprocess(str(archive)) + templates = extract_template_statistics_in_subprocess( + str(archive), + ("Use_of_property_types.md",), + ) + + assert entities["schema_identifier"] == "IFC4X3_ADD2" + assert psets["schema_identifier"] == "IFC4X3_ADD2" + assert len(templates) == 17 + + def test_pset_definition_resources_cover_schema_addenda(self): + assert pset_resource_schema("IFC2X3_TC1") == "IFC2X3" + assert pset_resource_schema("IFC4_ADD2") == "IFC4" + assert pset_resource_schema("IFC4X3_ADD2") == "IFC4X3" + assert "Pset_WallCommon" in standardized_pset_names("IFC4_ADD2") + assert "Definitely_Custom" not in standardized_pset_names("IFC4_ADD2") + + +class StatisticsQueryBuilderTests(TestCase): + @classmethod + def setUpTestData(cls): + user = get_user_model().objects.create_superuser( + username="statistics-query", + email="statistics@example.com", + password="unused", + ) + cls.user = user + cls.first = Model.objects.create( + file_name="first.ifc", + file="first.ifc", + size=1, + schema="IFC4", + uploaded_by=user, + ) + cls.second = Model.objects.create( + file_name="second.ifc", + file="second.ifc", + size=1, + schema="IFC4", + uploaded_by=user, + ) + + indices = { + name: EntityCountHistogram.index_from_string("IFC4", name) + for name in ( + "IfcDoor", "IfcBuildingElementProxy", "IfcElement", "IfcProject", + "IfcWall", + ) + } + EntityCountHistogram.objects.bulk_create([ + EntityCountHistogram( + model=cls.first, + entity_index=indices[name], + count=count, + is_supertype=False, + ) + for name, count in ( + ("IfcWall", 10), + ("IfcDoor", 5), + ("IfcBuildingElementProxy", 2), + ("IfcProject", 1), + ) + ] + [ + EntityCountHistogram( + model=cls.second, + entity_index=indices[name], + count=count, + is_supertype=False, + ) + for name, count in (("IfcWall", 30), ("IfcDoor", 5)) + ] + [ + EntityCountHistogram( + model=model, + entity_index=indices["IfcElement"], + count=count, + is_supertype=True, + ) + for model, count in ((cls.first, 17), (cls.second, 35)) + ] + [ + EntityCountHistogram.completion_marker(model) + for model in (cls.first, cls.second) + ]) + PsetCountHistogram.objects.bulk_create([ + PsetCountHistogram( + model=model, + entity_index=None, + pset_name=name, + is_standardized=standardized, + count=count, + ) + for model, name, standardized, count in ( + (cls.first, "Pset_WallCommon", True, 8), + (cls.first, "Custom_First", False, 2), + (cls.second, "Pset_WallCommon", True, 2), + (cls.second, "Custom_Second", False, 8), + ) + ] + [ + PsetCountHistogram( + model=model, + entity_index=indices[entity], + pset_name=name, + is_standardized=standardized, + count=count, + ) + for model, entity, name, standardized, count in ( + (cls.first, "IfcWall", "Pset_WallCommon", True, 8), + (cls.first, "IfcDoor", "Custom_First", False, 2), + (cls.second, "IfcWall", "Pset_WallCommon", True, 2), + (cls.second, "IfcDoor", "Custom_Second", False, 8), + ) + ] + [ + PsetCountHistogram.completion_marker(model) + for model in (cls.first, cls.second) + ]) + + first_wall = ModelInstance.objects.create( + model=cls.first, + stepfile_id=1, + ifc_type="IfcWall", + ) + first_door = ModelInstance.objects.create( + model=cls.first, + stepfile_id=2, + ifc_type="IfcDoor", + ) + second_wall = ModelInstance.objects.create( + model=cls.second, + stepfile_id=1, + ifc_type="IfcWall", + ) + TemplateStatistic.objects.bulk_create([ + TemplateStatistic( + model=cls.first, + template_name="Template_A.md", + focus_instance=first_wall, + graph={"value": "first wall"}, + ), + TemplateStatistic( + model=cls.first, + template_name="Template_A.md", + focus_instance=first_door, + graph={"value": "first door"}, + ), + TemplateStatistic( + model=cls.second, + template_name="Template_A.md", + focus_instance=second_wall, + graph={"value": "second wall"}, + ), + TemplateStatistic( + model=cls.second, + template_name="Template_B.md", + focus_instance=second_wall, + graph={"value": "second template"}, + ), + *[ + TemplateStatistic( + model=model, + template_name=template_name, + graph=None, + ) + for model in (cls.first, cls.second) + for template_name in dict.fromkeys(( + *available_template_names(), + "Template_A.md", + "Template_B.md", + )) + ], + ]) + + @staticmethod + def execute(**overrides): + specification = { + "source": "entity", + "group_by": "entity", + "expression": "count", + "ordering": "descending", + "limit": 10, + "filters": [{ + "field": "schema", + "operator": "eq", + "value": "IFC4", + "typed_value": "IFC4", + }], + } + specification.update(overrides) + expressions = { + "count": StatisticsExpression(), + "avg(count)": StatisticsExpression("average"), + "models": StatisticsExpression("count_distinct", "model"), + "count / total_count": StatisticsExpression( + operator="divide", operand_b="total_count", + ), + "count / computed_models": StatisticsExpression( + operator="divide", operand_b="computed_models", + ), + "avg(count / model_total_count)": StatisticsExpression( + "average", operator="divide", operand_b="model_total_count", + ), + } + groups = specification["group_by"] + if isinstance(groups, str): + groups = (groups,) + groups = tuple("pset_name" if group == "pset" else group for group in groups) + query = StatisticsQuery( + specification["source"], + groups, + expressions[specification["expression"]], + specification["ordering"], + specification["limit"], + tuple( + QueryFilter(clause["field"], clause["operator"], clause["typed_value"]) + for clause in specification["filters"] + ), + ) + return StatisticsQueryBuilder(query).execute() + + @staticmethod + def clause(field, operator, value, typed_value=None): + return { + "field": field, + "operator": operator, + "value": str(value), + "typed_value": value if typed_value is None else typed_value, + } + + @staticmethod + def expression(function="", operand_a="count", operator="", operand_b=""): + return { + "operation": "expression", + "expression_function": function, + "operand_a": operand_a, + "expression_operator": operator, + "operand_b": operand_b, + } + + def post_query(self, clauses, source="entity"): + self.client.force_login(self.user) + data = { + "source": source, + "clauses-TOTAL_FORMS": len(clauses), + "clauses-INITIAL_FORMS": 0, + "clauses-MIN_NUM_FORMS": 0, + "clauses-MAX_NUM_FORMS": 50, + } + for index, clause in enumerate(clauses): + for field, value in clause.items(): + data[f"clauses-{index}-{field}"] = value + return self.client.post( + reverse("admin:ifc_validation_models_model_statistics"), + data, + ) + + def test_zero_count_completion_markers_are_never_query_results(self): + entity_result = self.execute(limit=100) + pset_result = self.execute( + source="pset", + group_by="pset", + limit=100, + filters=[self.clause("schema", "eq", "IFC4")], + ) + + assert all(row[-1] > 0 for row in entity_result.rows) + assert all(row[-1] > 0 for row in pset_result.rows) + assert "(unnamed)" not in {row[0] for row in pset_result.rows} + assert self.first.get_histogram() + assert all(count > 0 for count in self.first.get_histogram().values()) + + def test_empty_histogram_tasks_replace_rows_and_create_completion_markers(self): + model = Model.objects.create( + file_name="empty.ifc", + file="empty.ifc", + size=1, + schema="IFC4", + uploaded_by=self.user, + ) + task_module = "apps.ifc_validation.tasks.statistics_tasks" + with ( + patch(f"{task_module}.get_absolute_file_path", return_value="empty.ifc"), + patch( + f"{task_module}.extract_entity_histogram_in_subprocess", + return_value={"schema_identifier": "IFC4", "entries": []}, + ) as extract_entities, + patch( + f"{task_module}.extract_pset_histogram_in_subprocess", + return_value={"schema_identifier": "IFC4", "entries": []}, + ) as extract_psets, + ): + assert populate_entity_count_histogram.run(model.pk) == 0 + assert populate_pset_count_histogram.run(model.pk) == 0 + assert populate_entity_count_histogram.run(model.pk) == 0 + assert populate_pset_count_histogram.run(model.pk) == 0 + + entity_marker = model.histogram_entries.get(count=0) + pset_marker = model.pset_count_entries.get(count=0) + assert entity_marker.is_completion_marker + assert entity_marker.is_supertype is None + assert pset_marker.is_completion_marker + assert pset_marker.entity_index is None + assert extract_entities.call_count == 2 + assert extract_psets.call_count == 2 + assert "histogram completed" in str(entity_marker) + assert "histogram completed" in str(pset_marker) + + def test_failed_subprocesses_create_completion_markers(self): + model = Model.objects.create( + file_name="invalid.ifc", + file="invalid.ifc", + size=1, + schema="IFC4", + status_syntax=Model.Status.VALID, + uploaded_by=self.user, + ) + task_module = "apps.ifc_validation.tasks.statistics_tasks" + template_names = available_template_names() + with ( + patch( + f"{task_module}.get_absolute_file_path", + return_value="invalid.ifc", + ), + patch( + f"{task_module}.extract_entity_histogram_in_subprocess", + side_effect=RuntimeError("entity extraction failed"), + ), + patch( + f"{task_module}.extract_pset_histogram_in_subprocess", + side_effect=RuntimeError("pset extraction failed"), + ), + patch( + f"{task_module}.extract_template_statistics_in_subprocess", + side_effect=RuntimeError("template extraction failed"), + ), + ): + assert populate_entity_count_histogram.run(model.pk) == 0 + assert populate_pset_count_histogram.run(model.pk) == 0 + assert populate_template_statistics.run( + model.pk, + template_names, + ) == 0 + + assert model.histogram_entries.filter(count=0).count() == 1 + assert model.pset_count_entries.filter(count=0).count() == 1 + assert set( + model.template_statistics.filter(graph__isnull=True).values_list( + "template_name", + flat=True, + ), + ) == set(template_names) + + with ( + patch(f"{task_module}.psutil.cpu_percent", return_value=0), + patch( + f"{task_module}.available_template_names", + return_value=template_names, + ), + patch(f"{task_module}.group") as task_group, + ): + assert schedule_model_statistic_tasks.run(batch_size=10) == 0 + task_group.assert_not_called() + + def test_statistic_tasks_fall_back_to_retained_gzip_file(self): + model = Model.objects.create( + file_name="archived.ifc", + file="archived.ifc", + size=1, + schema="IFC4", + uploaded_by=self.user, + ) + task_module = "apps.ifc_validation.tasks.statistics_tasks" + + def resolve_file(file_name): + if file_name == "archived.ifc.gz": + return "/files_storage/archived.ifc.gz" + raise FileNotFoundError(file_name) + + with ( + patch( + f"{task_module}.get_absolute_file_path", + side_effect=resolve_file, + ), + patch( + f"{task_module}.extract_entity_histogram_in_subprocess", + return_value={"schema_identifier": "IFC4", "entries": []}, + ) as extract_entities, + patch( + f"{task_module}.extract_pset_histogram_in_subprocess", + return_value={"schema_identifier": "IFC4", "entries": []}, + ) as extract_psets, + patch( + f"{task_module}.extract_template_statistics_in_subprocess", + return_value=[], + ) as extract_templates, + ): + assert populate_entity_count_histogram.run(model.pk) == 0 + assert populate_pset_count_histogram.run(model.pk) == 0 + assert populate_template_statistics.run(model.pk, ("First.md",)) == 0 + + archive = "/files_storage/archived.ifc.gz" + extract_entities.assert_called_once_with(archive) + extract_psets.assert_called_once_with(archive) + extract_templates.assert_called_once_with(archive, ("First.md",)) + + def test_statistic_tasks_skip_when_original_and_archive_are_deleted(self): + model = Model.objects.create( + file_name="deleted.ifc", + file="deleted.ifc", + size=1, + schema="IFC4", + uploaded_by=self.user, + ) + task_module = "apps.ifc_validation.tasks.statistics_tasks" + with ( + patch( + f"{task_module}.get_absolute_file_path", + side_effect=FileNotFoundError, + ), + patch( + f"{task_module}.extract_entity_histogram_in_subprocess", + ) as extract_entities, + patch( + f"{task_module}.extract_pset_histogram_in_subprocess", + ) as extract_psets, + patch( + f"{task_module}.extract_template_statistics_in_subprocess", + ) as extract_templates, + ): + assert populate_entity_count_histogram.run(model.pk) == 0 + assert populate_pset_count_histogram.run(model.pk) == 0 + assert populate_template_statistics.run(model.pk, ("First.md",)) == 0 + + extract_entities.assert_not_called() + extract_psets.assert_not_called() + extract_templates.assert_not_called() + assert not model.histogram_entries.exists() + assert not model.pset_count_entries.exists() + assert not model.template_statistics.exists() + + def test_histogram_data_rows_have_database_uniqueness_constraints(self): + entity = self.first.histogram_entries.filter( + count__gt=0, + is_supertype=False, + ).first() + pset = self.first.pset_count_entries.filter( + count__gt=0, + entity_index__isnull=False, + ).first() + + with self.assertRaises(IntegrityError), transaction.atomic(): + EntityCountHistogram.objects.create( + model=entity.model, + entity_index=entity.entity_index, + is_supertype=entity.is_supertype, + count=999, + ) + with self.assertRaises(IntegrityError), transaction.atomic(): + PsetCountHistogram.objects.create( + model=pset.model, + entity_index=pset.entity_index, + pset_name=pset.pset_name, + is_standardized=pset.is_standardized, + count=999, + ) + + def test_scheduler_uses_completion_markers_instead_of_data_rows(self): + model = Model.objects.create( + file_name="pending.ifc", + file="pending.ifc", + size=1, + schema="IFC4", + status_syntax=Model.Status.VALID, + uploaded_by=self.user, + ) + removed_model = Model.objects.create( + file_name="removed.ifc", + file="removed.ifc", + size=1, + schema="IFC4", + status_syntax=Model.Status.VALID, + uploaded_by=self.user, + ) + Model.objects.create( + file_name="syntax-invalid.ifc", + file="syntax-invalid.ifc", + size=1, + schema="IFC4", + status_syntax=Model.Status.INVALID, + uploaded_by=self.user, + ) + Model.objects.create( + file_name="syntax-not-validated.ifc", + file="syntax-not-validated.ifc", + size=1, + schema="IFC4", + uploaded_by=self.user, + ) + ValidationRequest.objects.bulk_create([ + ValidationRequest( + file_name="removed.ifc", + file="", + file_removed=timezone.now(), + size=1, + model=removed_model, + created_by=self.user, + ), + ]) + TemplateStatistic.objects.bulk_create([ + TemplateStatistic( + model=completed_model, + template_name=template_name, + graph=None, + ) + for completed_model in (self.first, self.second) + for template_name in ("First.md", "Second.md") + ]) + task_module = "apps.ifc_validation.tasks.statistics_tasks" + with ( + patch(f"{task_module}.psutil.cpu_percent", return_value=0), + patch( + f"{task_module}.available_template_names", + return_value=("First.md", "Second.md"), + ), + patch(f"{task_module}.group") as task_group, + ): + assert schedule_model_statistic_tasks.run(batch_size=10) == 3 + task_group.return_value.apply_async.assert_called_once_with() + + task_group.reset_mock() + EntityCountHistogram.completion_marker(model).save() + PsetCountHistogram.completion_marker(model).save() + assert schedule_model_statistic_tasks.run(batch_size=10) == 1 + + task_group.reset_mock() + TemplateStatistic.objects.create( + model=model, + template_name="First.md", + graph=None, + ) + assert schedule_model_statistic_tasks.run(batch_size=10) == 1 + task_group.return_value.apply_async.assert_called_once_with() + + task_group.reset_mock() + TemplateStatistic.objects.create( + model=model, + template_name="Second.md", + graph=None, + ) + assert schedule_model_statistic_tasks.run(batch_size=10) == 0 + task_group.assert_not_called() + + def test_template_task_replaces_selected_templates_and_marks_each_one(self): + model = Model.objects.create( + file_name="templates.ifc", + file="templates.ifc", + size=1, + schema="IFC4", + uploaded_by=self.user, + ) + task_module = "apps.ifc_validation.tasks.statistics_tasks" + extracted_result = { + "template": "First.md", + "focus_step_id": 42, + "focus_ifc_type": "IfcWall", + "graph": {"PropertyType": "IfcPropertySingleValue"}, + } + with ( + patch( + f"{task_module}.extract_template_statistics_in_subprocess", + return_value=[extracted_result], + ) as extract, + patch( + f"{task_module}.get_absolute_file_path", + return_value="templates.ifc", + ), + ): + assert populate_template_statistics.run( + model.pk, + ("First.md", "Second.md"), + ) == 1 + assert populate_template_statistics.run( + model.pk, + ("First.md", "Second.md"), + ) == 1 + + markers = model.template_statistics.filter(graph__isnull=True) + assert set(markers.values_list("template_name", flat=True)) == { + "First.md", + "Second.md", + } + assert all(marker.is_completion_marker for marker in markers) + assert "First.md statistics completed" in str( + markers.get(template_name="First.md"), + ) + assert model.template_statistics.get( + graph__isnull=False, + ).graph == extracted_result["graph"] + extract.assert_called_with( + "templates.ifc", + ("First.md", "Second.md"), + ) + + extract.return_value = [] + assert populate_template_statistics.run( + model.pk, + ("Third.md",), + ) == 0 + assert model.template_statistics.filter( + template_name="Third.md", + graph__isnull=True, + ).exists() + assert extract.call_args.args == ("templates.ifc", ("Third.md",)) + + def test_management_command_treats_markers_as_completed(self): + stdout = StringIO() + + call_command("populate_statistics", 1, stdout=stdout) + + assert "Processed 0 model(s)" in stdout.getvalue() + + def test_management_command_processes_a_missing_template_marker(self): + model = Model.objects.create( + file_name="new-template.ifc", + file="new-template.ifc", + size=1, + schema="IFC4", + uploaded_by=self.user, + ) + EntityCountHistogram.completion_marker(model).save() + PsetCountHistogram.completion_marker(model).save() + template_names = available_template_names() + TemplateStatistic.objects.bulk_create([ + TemplateStatistic( + model=model, + template_name=template_name, + graph=None, + ) + for template_name in template_names[:-1] + ]) + stdout = StringIO() + task_module = "apps.ifc_validation.tasks.statistics_tasks" + + with ( + patch( + f"{task_module}.extract_template_statistics_in_subprocess", + return_value=[], + ), + patch( + f"{task_module}.get_absolute_file_path", + return_value="new-template.ifc", + ), + ): + call_command("populate_statistics", 1, stdout=stdout) + + assert "Processed 1 model(s)" in stdout.getvalue() + assert model.template_statistics.filter( + template_name=template_names[-1], + graph__isnull=True, + ).exists() + + def test_top_element_subtypes_for_model(self): + result = self.execute(filters=[ + self.clause("model", "eq", self.first.pk), + self.clause("entity", "subtype_of", "IfcElement"), + self.clause("entity_kind", "eq", "concrete", False), + ]) + + assert result.columns == ["Schema", "Entity", "count"] + assert result.rows == [ + ["IFC4", "IfcWall", 10], + ["IFC4", "IfcDoor", 5], + ["IFC4", "IfcBuildingElementProxy", 2], + ] + + def test_average_entity_counts_and_number_of_models(self): + average = self.execute( + expression="avg(count)", + filters=[ + self.clause("schema", "eq", "IFC4"), + self.clause("entity", "subtype_of", "IfcElement"), + self.clause("entity_kind", "eq", "concrete", False), + ], + ) + model_count = self.execute( + expression="models", + filters=[ + self.clause("schema", "eq", "IFC4"), + self.clause("entity", "eq", "IfcWall"), + self.clause("count", "gt", 0), + ], + ) + + assert average.rows[0] == ["IFC4", "IfcWall", 20] + assert model_count.rows == [["IFC4", "IfcWall", 2]] + + def test_explicit_division_by_computed_models(self): + result = self.execute( + expression="count / computed_models", + filters=[ + self.clause("schema", "eq", "IFC4"), + self.clause("entity", "eq", "IfcWall"), + self.clause("entity_kind", "eq", "concrete", False), + ], + ) + + assert result.rows == [["IFC4", "IfcWall", 20]] + + def test_property_set_ratio_and_average_ratio(self): + one_model = self.execute( + source="pset", + group_by="standardized", + expression="count / total_count", + filters=[ + self.clause("model", "eq", self.first.pk), + self.clause("pset_scope", "eq", "definitions", True), + ], + ) + schema_average = self.execute( + source="pset", + group_by="standardized", + expression="avg(count / model_total_count)", + filters=[ + self.clause("schema", "eq", "IFC4"), + self.clause("pset_scope", "eq", "definitions", True), + ], + ) + + assert one_model.rows == [["Standard", .8], ["Custom", .2]] + assert dict(schema_average.rows) == {"Standard": .5, "Custom": .5} + + def test_proxy_ratio_uses_filtered_element_total(self): + result = self.execute( + group_by="proxy", + expression="count / total_count", + filters=[ + self.clause("model", "eq", self.first.pk), + self.clause("entity", "subtype_of", "IfcElement"), + self.clause("entity_kind", "eq", "concrete", False), + ], + ) + + assert result.rows[0][0] == "Other element subtypes" + self.assertAlmostEqual(result.rows[0][1], 15 / 17) + assert result.rows[1][0] == "Proxy" + self.assertAlmostEqual(result.rows[1][1], 2 / 17) + + def test_entity_origin_and_negated_subtype_filters(self): + inherited = self.execute(filters=[ + self.clause("model", "eq", self.first.pk), + self.clause("entity_kind", "eq", "inherited", True), + ]) + outside_elements = self.execute(filters=[ + self.clause("model", "eq", self.first.pk), + self.clause("entity", "not_subtype_of", "IfcElement"), + self.clause("entity_kind", "eq", "concrete", False), + ]) + + assert inherited.rows == [["IFC4", "IfcElement", 17]] + assert outside_elements.rows == [["IFC4", "IfcProject", 1]] + + def test_ordering_and_limit_apply_to_selected_value(self): + result = self.execute( + ordering="ascending", + limit=2, + filters=[ + self.clause("schema", "eq", "IFC4"), + self.clause("entity_kind", "eq", "concrete", False), + ], + ) + + assert result.rows == [ + ["IFC4", "IfcProject", 1], + ["IFC4", "IfcBuildingElementProxy", 2], + ] + + def test_property_set_totals_by_name_and_associated_entity(self): + definitions = self.execute( + source="pset", + group_by="pset", + filters=[ + self.clause("model", "eq", self.first.pk), + self.clause("pset_scope", "eq", "definitions", True), + ], + ) + associations = self.execute( + source="pset", + group_by="entity", + filters=[ + self.clause("model", "eq", self.first.pk), + self.clause("pset_scope", "eq", "associations", False), + ], + ) + + assert definitions.rows == [ + ["Pset_WallCommon", 8], + ["Custom_First", 2], + ] + assert associations.rows == [ + ["IFC4", "IfcWall", 8], + ["IFC4", "IfcDoor", 2], + ] + + def test_property_set_text_and_standardization_filters(self): + standard_wall_sets = self.execute( + source="pset", + group_by="pset", + filters=[ + self.clause("schema", "eq", "IFC4"), + self.clause("pset_scope", "eq", "definitions", True), + self.clause("pset_name", "contains", "wall"), + self.clause("standardized", "eq", "standard", True), + ], + ) + custom_sets = self.execute( + source="pset", + group_by="pset", + filters=[ + self.clause("schema", "eq", "IFC4"), + self.clause("pset_scope", "eq", "definitions", True), + self.clause("standardized", "eq", "custom", False), + ], + ) + + assert standard_wall_sets.rows == [["Pset_WallCommon", 10]] + assert custom_sets.rows == [["Custom_Second", 8], ["Custom_First", 2]] + + def test_template_statistics_have_meaningful_groups_and_expressions(self): + totals = self.execute( + source="template", + group_by="template", + filters=[self.clause("schema", "eq", "IFC4")], + ) + model_counts = self.execute( + source="template", + group_by="template", + expression="models", + filters=[self.clause("schema", "eq", "IFC4")], + ) + focus_percentages = self.execute( + source="template", + group_by="entity", + expression="count / total_count", + filters=[self.clause("schema", "eq", "IFC4")], + ) + name_filter = self.execute( + source="template", + group_by="template", + filters=[ + self.clause("schema", "eq", "IFC4"), + self.clause("template", "not_contains", "Template_B"), + ], + ) + + assert totals.rows == [["Template_A.md", 3], ["Template_B.md", 1]] + assert model_counts.rows == [["Template_A.md", 2], ["Template_B.md", 1]] + assert focus_percentages.rows == [["IfcWall", .75], ["IfcDoor", .25]] + assert name_filter.rows == [["Template_A.md", 3]] + + def test_template_graph_values_can_be_grouped_with_authoring_tool(self): + tool = AuthoringTool.objects.create(name="Example CAD", version="2026") + self.first.produced_by = tool + self.first.save(update_fields=["produced_by"]) + TemplateStatistic.objects.bulk_create([ + TemplateStatistic( + model=self.first, + template_name="Use_of_property_types.md", + graph={"PropertyType": "IfcPropertySingleValue"}, + ), + TemplateStatistic( + model=self.first, + template_name="Use_of_property_types.md", + graph={"PropertyType": "IfcPropertySingleValue"}, + ), + TemplateStatistic( + model=self.first, + template_name="Use_of_property_types.md", + graph={"PropertyType": "IfcPropertyEnumeratedValue"}, + ), + ]) + + result = self.execute( + source="template", + group_by=["authoring_tool", "graph_value:PropertyType"], + filters=[self.clause( + "template", "eq", "Use_of_property_types.md", + )], + limit=None, + ) + + assert result.columns == [ + "Authoring tool ID", + "Authoring tool", + "Version", + "Graph: PropertyType", + "count", + ] + assert result.rows == [ + [tool.pk, "Example CAD", "2026", "IfcPropertySingleValue", 2], + [tool.pk, "Example CAD", "2026", "IfcPropertyEnumeratedValue", 1], + ] + assert "->>" in result.sql or "#>>" in result.sql + + def test_template_graph_value_supports_a_single_model_basis_query(self): + TemplateStatistic.objects.bulk_create([ + TemplateStatistic( + model=self.first, + template_name="Usage_of_transition_curves_geometry.md", + graph={"ParentCurve": "IfcClothoid"}, + ), + TemplateStatistic( + model=self.first, + template_name="Usage_of_transition_curves_geometry.md", + graph={"ParentCurve": "IfcClothoid"}, + ), + TemplateStatistic( + model=self.first, + template_name="Usage_of_transition_curves_geometry.md", + graph={"ParentCurve": "IfcPolynomialCurve"}, + ), + ]) + + result = self.execute( + source="template", + group_by="graph_value:ParentCurve", + filters=[ + self.clause("model", "eq", self.first.pk), + self.clause( + "template", "eq", "Usage_of_transition_curves_geometry.md", + ), + ], + limit=None, + ) + + assert result.columns == ["Graph: ParentCurve", "count"] + assert result.rows == [["IfcClothoid", 2], ["IfcPolynomialCurve", 1]] + + def test_template_graph_value_supports_reusable_nested_paths(self): + TemplateStatistic.objects.create( + model=self.first, + template_name="Nested.md", + graph={"Property": {"Type": "IfcPropertyListValue"}}, + ) + + result = self.execute( + source="template", + group_by="graph_value:Property.Type", + filters=[self.clause("template", "eq", "Nested.md")], + ) + + assert result.columns == ["Graph: Property.Type", "count"] + assert result.rows == [["IfcPropertyListValue", 1]] + assert "#>>" in result.sql + + def test_incompatible_compositions_raise_from_query_builder(self): + with self.assertRaisesRegex(ValueError, "not available"): + self.execute( + source="template", + group_by="template", + filters=[self.clause("count", "gt", 0)], + ) + with self.assertRaisesRegex(ValueError, "one model or one exact schema"): + self.execute( + filters=[self.clause("entity", "eq", "IfcWall")], + ) + with self.assertRaisesRegex(ValueError, "Proxy grouping requires"): + self.execute(group_by="proxy", filters=[]) + + def test_minimal_query_renders_schema_and_entity_cells(self): + response = self.post_query([ + {"operation": "group", "target": "group:entity"}, + self.expression(), + ]) + + assert response.status_code == 200 + assert response.context["rows"][0] == ["IFC4", "IfcElement", 52] + assert b"IFC4" in response.content + assert b"IfcElement" in response.content + assert b"52" in response.content + assert b'id="statistics-copy-results"' in response.content + assert b"Copy results for Excel" in response.content + assert b"tableToTsv" in response.content + assert "\nFROM " in response.context["sql"] + + def test_model_admin_histogram_links_target_statistics_queries(self): + model_admin = django_admin.site._registry[Model] + + entity_link = str(model_admin.histogram_link(self.first)) + pset_link = str(model_admin.pset_histogram_link(self.first)) + + assert "/model/statistics/?source=entity&model=" in entity_link + assert "/model/statistics/?source=pset&model=" in pset_link + assert "/histogram/" not in entity_link + assert "/pset-histogram/" not in pset_link + for removed_url_name in ( + "admin:ifc_validation_models_model_histograms", + "admin:ifc_validation_models_model_pset_histograms", + "admin:ifc_validation_models_model_histogram", + "admin:ifc_validation_models_model_pset_histogram", + ): + with self.assertRaises(NoReverseMatch): + reverse(removed_url_name) + + def test_entity_histogram_statistics_link_executes_equivalent_query(self): + self.client.force_login(self.user) + response = self.client.get( + reverse("admin:ifc_validation_models_model_statistics"), + {"source": "entity", "model": self.first.pk}, + ) + + assert response.status_code == 200 + assert response.context["query_error"] == "" + assert response.context["clause_formset"].is_valid() + assert response.context["clause_formset"].forms[-1].cleaned_data[ + "resolved_value" + ] is None + assert { + row[1]: row[-1] + for row in response.context["rows"] + } == self.first.get_histogram(include_supertypes=True) + + def test_pset_histogram_statistics_link_executes_equivalent_query(self): + self.client.force_login(self.user) + response = self.client.get( + reverse("admin:ifc_validation_models_model_statistics"), + {"source": "pset", "model": self.first.pk}, + ) + + expected = { + ( + entry.entity_name, + entry.pset_name or "(unnamed)", + "Standard" if entry.is_standardized else "Custom", + ): entry.count + for entry in self.first.pset_count_entries.filter(count__gt=0) + } + assert response.status_code == 200 + assert response.context["query_error"] == "" + assert response.context["clause_formset"].is_valid() + assert response.context["clause_formset"].forms[-1].cleaned_data[ + "resolved_value" + ] is None + assert response.context["columns"] == [ + "Schema", "Entity", "Property set", "Standardized", "count", + ] + assert { + (row[1], row[2], row[3]): row[-1] + for row in response.context["rows"] + } == expected + + def test_expression_ui_renders_only_structured_dropdowns(self): + self.client.force_login(self.user) + response = self.client.get( + reverse("admin:ifc_validation_models_model_statistics"), + ) + + assert response.status_code == 200 + for field in ( + "expression_function", + "operand_a", + "expression_operator", + "operand_b", + ): + assert f'name="clauses-__prefix__-{field}"'.encode() in response.content + assert b"statistics-expression-values" not in response.content + assert b"statistics-example-clause" in response.content + assert b"Top 10 element subtypes used in one file" in response.content + assert b"Average proxy ratio in files of an IFC version" in response.content + assert response.content.count(b"data-example-index=") == 13 + assert b'id="statistics-query-examples"' in response.content + + def test_source_controls_available_filter_and_group_choices(self): + context = statistics_query_ui_context() + + entity_filters = { + choice["value"] for choice in context["clause_target_choices"]["filter"]["entity"] + } + pset_filters = { + choice["value"] for choice in context["clause_target_choices"]["filter"]["pset"] + } + template_filters = { + choice["value"] + for choice in context["clause_target_choices"]["filter"]["template"] + } + template_groups = { + choice["value"] for choice in context["clause_target_choices"]["group"]["template"] + } + assert "filter:pset_name" not in entity_filters + assert "filter:pset_name" in pset_filters + assert "filter:count" not in template_filters + assert "group:template" in template_groups + assert "group:authoring_tool" in template_groups + assert "group:graph_value" in template_groups + + def test_all_requested_example_query_patterns_are_available(self): + examples = statistics_query_ui_context()["statistics_query_examples"] + + assert [example["title"] for example in examples] == [ + "Top 10 element subtypes used in one file", + "Average top 10 element subtypes used in files of an IFC version", + "Number of files of an IFC version containing an entity", + "Top 10 property sets used in one file", + "Average top 10 property sets used in files of an IFC version", + "Ratio of standard versus custom property sets in one file", + "Average ratio of standard versus custom property sets by IFC version", + "Ratio of proxy versus other element subtypes in one file", + "Average proxy ratio in files of an IFC version", + "Property type counts grouped by AuthoringTool", + "Property type counts for a single model", + "Basis counts grouped by AuthoringTool", + "Basis type counts for a single model", + ] + for example in examples: + operations = [clause["operation"] for clause in example["clauses"]] + assert operations.count("Group by") >= 1 + assert operations.count("Expression") == 1 + + expressions = [ + next(clause for clause in example["clauses"] if clause["operation"] == "Expression") + for example in examples + ] + assert expressions[5]["expression"] == { + "function": "๐‘“", + "function_active": False, + "operand_a": "count", + "operator": "รท", + "operator_active": True, + "operand_b": "total count", + "operand_b_active": True, + } + assert expressions[6]["expression"]["function"] == "AVG" + assert expressions[6]["expression"]["operand_b"] == ( + "model total count" + ) + + def test_every_example_payload_executes_through_the_admin_builder(self): + tool = AuthoringTool.objects.create(name="Example CAD", version="2026") + self.first.produced_by = tool + self.first.save(update_fields=["produced_by"]) + TemplateStatistic.objects.bulk_create([ + TemplateStatistic( + model=self.first, + template_name="Use_of_property_types.md", + graph={"PropertyType": "IfcPropertySingleValue"}, + ), + TemplateStatistic( + model=self.first, + template_name="Usage_of_transition_curves_geometry.md", + graph={"ParentCurve": "IfcClothoid"}, + ), + ]) + examples = statistics_query_ui_context()["statistics_query_examples"] + + for example in examples: + with self.subTest(example=example["title"]): + form_data = example["form_data"] + clauses = [clause.copy() for clause in form_data["clauses"]] + for clause in clauses: + if clause.get("target") == "filter:model": + clause["value"] = self.first.pk + response = self.post_query(clauses, source=form_data["source"]) + + assert response.status_code == 200 + assert response.context["clause_formset"].is_valid() + assert response.context["query_error"] == "" + assert response.context["rows"] + + def test_order_and_limit_clauses_are_built_from_admin_formset(self): + response = self.post_query([ + {"operation": "group", "target": "group:entity"}, + self.expression(function="sum"), + {"operation": "order", "target": "order:ascending"}, + {"operation": "limit", "value": 2}, + { + "operation": "filter", + "target": "filter:entity_kind", + "operator": "eq", + "value": "concrete", + }, + ]) + + assert response.status_code == 200 + assert response.context["rows"] == [ + ["IFC4", "IfcProject", 1], + ["IFC4", "IfcBuildingElementProxy", 2], + ] + + def test_omitting_limit_clause_does_not_apply_an_implicit_limit(self): + response = self.post_query([ + {"operation": "group", "target": "group:entity"}, + self.expression(function="sum"), + {"operation": "order", "target": "order:descending"}, + ]) + + assert response.status_code == 200 + assert response.context["query_error"] == "" + assert "LIMIT" not in response.context["sql"] + + def test_admin_post_builds_result_without_persisting_a_report(self): + before = EntityCountHistogram.objects.count() + response = self.post_query([ + {"operation": "group", "target": "group:entity"}, + self.expression(), + { + "operation": "filter", + "target": "filter:model", + "operator": "eq", + "value": self.first.pk, + }, + { + "operation": "filter", + "target": "filter:entity", + "operator": "subtype_of", + "value": "IfcElement", + }, + { + "operation": "filter", + "target": "filter:entity_kind", + "operator": "eq", + "value": "concrete", + }, + ]) + + assert response.status_code == 200 + assert response.context["rows"][0] == ["IFC4", "IfcWall", 10] + assert b"IFC4" in response.content + assert b"IfcWall" in response.content + assert "SELECT" in response.context["sql"] + assert EntityCountHistogram.objects.count() == before + + def test_repeated_count_filters_form_a_range(self): + result = self.execute(filters=[ + self.clause("model", "eq", self.first.pk), + self.clause("count", "gt", 4), + self.clause("count", "lt", 10), + ]) + + assert result.rows == [["IFC4", "IfcDoor", 5]] + + def test_admin_formset_accepts_repeated_count_filters(self): + response = self.post_query([ + {"operation": "group", "target": "group:entity"}, + self.expression(), + { + "operation": "filter", + "target": "filter:model", + "operator": "eq", + "value": self.first.pk, + }, + { + "operation": "filter", + "target": "filter:count", + "operator": "gt", + "value": 4, + }, + { + "operation": "filter", + "target": "filter:count", + "operator": "lt", + "value": 10, + }, + ]) + + assert response.status_code == 200 + assert response.context["rows"] == [["IFC4", "IfcDoor", 5]] + + def test_admin_expression_clause_accepts_explicit_division(self): + response = self.post_query( + [ + {"operation": "group", "target": "group:standardized"}, + self.expression( + operand_a="count", + operator="divide", + operand_b="total_count", + ), + { + "operation": "filter", + "target": "filter:model", + "operator": "eq", + "value": self.first.pk, + }, + { + "operation": "filter", + "target": "filter:pset_scope", + "operator": "eq", + "value": "definitions", + }, + ], + source="pset", + ) + + assert response.status_code == 200 + assert response.context["rows"] == [["Standard", 0.8], ["Custom", 0.2]] + + def test_admin_expression_supports_function_of_binary_operands(self): + response = self.post_query( + [ + {"operation": "group", "target": "group:standardized"}, + self.expression( + function="average", + operand_a="count", + operator="divide", + operand_b="model_total_count", + ), + { + "operation": "filter", + "target": "filter:schema", + "operator": "eq", + "value": "IFC4", + }, + { + "operation": "filter", + "target": "filter:pset_scope", + "operator": "eq", + "value": "definitions", + }, + ], + source="pset", + ) + + assert response.status_code == 200 + assert dict(response.context["rows"]) == {"Standard": 0.5, "Custom": 0.5} + + def test_invalid_function_combination_is_reported_as_query_error(self): + response = self.post_query([ + {"operation": "group", "target": "group:entity"}, + self.expression(function="sum", operand_a="model"), + ]) + + assert response.status_code == 200 + assert "Unsupported SUM expression" in response.context["query_error"] + + def test_invalid_composition_is_reported_by_backend_builder(self): + response = self.post_query([ + {"operation": "group", "target": "group:entity"}, + self.expression(), + self.expression(function="count_distinct", operand_a="model"), + ]) + + assert response.status_code == 200 + assert response.context["clause_formset"].is_valid() + assert response.context["query_error"] == ( + "The query requires exactly one expression clause." + ) + + def test_removed_clause_is_ignored_when_the_form_is_submitted(self): + response = self.post_query([ + {"operation": "group", "target": "group:entity"}, + self.expression(), + { + **self.expression(function="count_distinct", operand_a="model"), + "DELETE": "on", + }, + ]) + + assert response.status_code == 200 + assert response.context["query_error"] == "" + assert response.context["rows"][0] == ["IFC4", "IfcElement", 52] + + def test_all_supported_group_and_expression_combinations_execute(self): + expressions = ( + "count", + "avg(count)", + "models", + "count / total_count", + "avg(count / model_total_count)", + ) + + for source in SOURCES: + groups = [ + concept.name for concept in CONCEPTS + if concept.supports("group", source.name) + ] + for group in groups: + for expression in expressions: + with self.subTest(source=source.name, group=group, expression=expression): + resolved_group = ( + "graph_value:value" if group == "graph_value" else group + ) + result = self.execute( + source=source.name, + group_by=resolved_group, + expression=expression, + ) + assert result.columns + assert result.rows + assert all( + value not in (None, "") + for row in result.rows + for value in row[:-1] + ) diff --git a/backend/apps/ifc_validation_models b/backend/apps/ifc_validation_models index 7f814c00..3c8ebf8d 160000 --- a/backend/apps/ifc_validation_models +++ b/backend/apps/ifc_validation_models @@ -1 +1 @@ -Subproject commit 7f814c0009dd7fbbf2461c493aab9e9695610a14 +Subproject commit 3c8ebf8d02bc59527df5f921b1ff6fc14263fe9f diff --git a/backend/core/settings.py b/backend/core/settings.py index e1c6be20..f073d733 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -365,9 +365,19 @@ raise ImproperlyConfigured(msg.format(os.path.dirname(CELERY_BEAT_SCHEDULE_FILENAME), err)) ARCHIVE_FILES_LOOKBACK_PERIOD = os.environ.get("ARCHIVE_FILES_LOOKBACK_PERIOD", 90) -REMOVE_FILES_LOOKBACK_PERIOD = os.environ.get("REMOVE_FILES_LOOKBACK_PERIOD", 180) -CELERY_BEAT_SCHEDULE = { - 'archive-files-90days-every-15min': { +REMOVE_FILES_LOOKBACK_PERIOD = os.environ.get("REMOVE_FILES_LOOKBACK_PERIOD", 180) +MODEL_STATISTIC_BATCH_SIZE = int(os.environ.get("MODEL_STATISTIC_BATCH_SIZE", 100)) +MODEL_STATISTIC_CPU_THRESHOLD = float(os.environ.get("MODEL_STATISTIC_CPU_THRESHOLD", 50)) +CELERY_BEAT_SCHEDULE = { + 'schedule-model-statistic-tasks-every-15min': { + 'task': 'apps.ifc_validation.tasks.statistics_tasks.schedule_model_statistic_tasks', + 'schedule': crontab(minute='5,20,35,50'), + 'kwargs': { + 'batch_size': MODEL_STATISTIC_BATCH_SIZE, + 'cpu_threshold': MODEL_STATISTIC_CPU_THRESHOLD, + }, + }, + 'archive-files-90days-every-15min': { 'task': 'apps.ifc_validation.tasks.file_retention_tasks.apply_file_retention', 'schedule': crontab(minute='15,30,45'), # runs every 15 min, except at the hour 'kwargs': { 'days': ARCHIVE_FILES_LOOKBACK_PERIOD, 'dry_run': False, 'action': 'archive' }, @@ -488,4 +498,4 @@ # secure cookies if not (PUBLIC_URL is None or 'localhost' in PUBLIC_URL) and not DEBUG: CSRF_COOKIE_SECURE = True - SESSION_COOKIE_SECURE = True \ No newline at end of file + SESSION_COOKIE_SECURE = True diff --git a/docker-compose.yml b/docker-compose.yml index 7218c633..2ee012c7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,8 +91,10 @@ services: CELERY_TASK_SOFT_TIME_LIMIT: ${CELERY_TASK_SOFT_TIME_LIMIT} CELERY_TASK_TIME_LIMIT: ${CELERY_TASK_TIME_LIMIT} TASK_TIMEOUT_LIMIT: ${TASK_TIMEOUT_LIMIT} - CELERY_CONCURRENCY: ${CELERY_CONCURRENCY} - DJANGO_DB: ${DJANGO_DB} + CELERY_CONCURRENCY: ${CELERY_CONCURRENCY} + MODEL_STATISTIC_BATCH_SIZE: ${MODEL_STATISTIC_BATCH_SIZE:-100} + MODEL_STATISTIC_CPU_THRESHOLD: ${MODEL_STATISTIC_CPU_THRESHOLD:-50} + DJANGO_DB: ${DJANGO_DB} DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY} DJANGO_DB_BULK_CREATE_BATCH_SIZE: ${DJANGO_DB_BULK_CREATE_BATCH_SIZE} GHERKIN_LOG_FOLDER: ${GHERKIN_LOG_FOLDER} @@ -175,4 +177,4 @@ volumes: postgres_data: redis_data: gherkin_rules_log_data: - clamav_data: \ No newline at end of file + clamav_data: diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 80ec1c1e..ed0b6c85 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -30,6 +30,8 @@ RUN set -ex && \ RUN python3 -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" +ARG PYTHON_MVDXML_REF=support-4.x-graphviz-format-2 + # copy code and install requirements ADD ./backend /app/backend RUN --mount=type=cache,target=/root/.cache \ @@ -40,6 +42,11 @@ RUN --mount=type=cache,target=/root/.cache \ wget -O /tmp/ifcopenshell_python.zip "https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-311-v0.8.6-8b5b400-linux64.zip" && \ mkdir -p /opt/venv/lib/python3.11/site-packages && \ unzip -d /opt/venv/lib/python3.11/site-packages /tmp/ifcopenshell_python.zip && \ + wget -O /tmp/python-mvdxml.tar.gz "https://github.com/opensourceBIM/python-mvdxml/archive/refs/heads/${PYTHON_MVDXML_REF}.tar.gz" && \ + rm -rf /opt/venv/lib/python3.11/site-packages/ifcopenshell/mvd && \ + mkdir -p /opt/venv/lib/python3.11/site-packages/ifcopenshell/mvd && \ + tar -xzf /tmp/python-mvdxml.tar.gz --strip-components=1 -C /opt/venv/lib/python3.11/site-packages/ifcopenshell/mvd && \ + rm -f /tmp/ifcopenshell_python.zip /tmp/python-mvdxml.tar.gz && \ # some cleanup find / -type d -name setuptools -prune -exec rm -rf {} \; && \ find / -type d -name pip -prune -exec rm -rf {} \; && \ diff --git a/e2e/tests/django_admin.test.js b/e2e/tests/django_admin.test.js index a2577d32..2112d196 100644 --- a/e2e/tests/django_admin.test.js +++ b/e2e/tests/django_admin.test.js @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; const BASE_URL = 'http://localhost:8000/admin'; -const TEST_CREDENTIALS = 'root:root'; +const TEST_CREDENTIALS = process.env.DJANGO_ADMIN_CREDENTIALS || 'root:root'; import { execFileSync } from 'child_process'; import { resolve } from 'path'; @@ -141,7 +141,10 @@ test.describe('UI - Django Admin', () => { // navigate and check elements of the screen await page.goto(`${BASE_URL}/ifc_validation_models/`); await expect(page).toHaveURL(`${BASE_URL}/ifc_validation_models/`); - await expect(page.getByText('Statistics')).toBeVisible(); + await expect(page.getByRole('heading', { + name: 'Metrics & Statistics', + exact: true, + })).toBeVisible(); await expect(page.getByText('Choose a year')).toBeVisible(); // check some stats @@ -160,6 +163,35 @@ test.describe('UI - Django Admin', () => { await logout(page); }); + test('removed statistics query clauses stay removed after rerun', async ({ page }) => { + + await login(page); + await page.goto(`${BASE_URL}/ifc_validation_models/model/statistics/`); + + await page.locator('.statistics-examples > summary').click(); + await page.locator('.statistics-example-pattern').nth(1).locator('summary').click(); + await page.locator('.statistics-apply-example').nth(1).click(); + const limitRows = page.locator('.statistics-clause-row').filter({ + has: page.locator('select[name$="-operation"] option:checked', { hasText: 'Limit' }), + }); + const totalForms = page.locator('#id_clauses-TOTAL_FORMS'); + + await expect(limitRows).toHaveCount(1); + const initialClauseCount = await page.locator('.statistics-clause-row').count(); + await page.getByRole('button', { name: 'Run query' }).click(); + await expect(limitRows).toHaveCount(1); + + await limitRows.getByRole('button', { name: 'Remove' }).click(); + await expect(limitRows).toHaveCount(0); + await expect(totalForms).toHaveValue(String(initialClauseCount - 1)); + await page.getByRole('button', { name: 'Run query' }).click(); + + await expect(limitRows).toHaveCount(0); + await expect(totalForms).toHaveValue(String(initialClauseCount - 1)); + + await logout(page); + }); + test('top bar search for Validation Requests', async ({ page }) => { // login @@ -307,4 +339,4 @@ test.describe('UI - Django Admin', () => { await logout(page); }); -}); \ No newline at end of file +});