Skip to content
Snippets Groups Projects
main.py 18.8 KiB
Newer Older
# This file is part of dsiUnitsFrontend (https://gitlab1.ptb.de/digitaldynamicmeasurement/dsi-parser-frontend)
# Copyright 2024 [Benedikt Seeger(PTB), Vanessa Stehr(PTB)]
#This library is free software; you can redistribute it and/or
#modify it under the terms of the GNU Lesser General Public
#License as published by the Free Software Foundation; either
#version 2.1 of the License, or (at your option) any later version.

#This library is distributed in the hope that it will be useful,
#but WITHOUT ANY WARRANTY; without even the implied warranty of
#MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
#Lesser General Public License for more details.

#You should have received a copy of the GNU Lesser General Public
#License along with this library; if not, write to the Free Software
#Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA

Vanessa Stehr's avatar
Vanessa Stehr committed
import warnings
import itertools
import math
Vanessa Stehr's avatar
Vanessa Stehr committed
import bokehCssPTB
from urllib.parse import quote
from dsiUnits import dsiUnit
from bokeh.plotting import curdoc, figure
from bokeh.layouts import column, row, GridBox
from bokeh.models import FileInput, Div, CustomJS, Button, TabPanel, Tabs, TextInput, Label, Arrow, NormalHead, CheckboxGroup
from bokeh.palettes import Category10
Vanessa Stehr's avatar
Vanessa Stehr committed
from bokeh.events import ValueSubmit
import numpy as np
from base64 import b64decode
import XMLUnitExtractor

colors = Category10[10]
Vanessa Stehr's avatar
Vanessa Stehr committed
VERSION = "0.1.0"
import socket

def get_local_ip():
    try:
        # Creates a socket connection to Google's public DNS server to determine the internal network IP
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.connect(("8.8.8.8", 80))  # Using Google DNS, and connecting via dummy request
            local_ip = s.getsockname()[0]  # Returns the IP where the socket is connected
        return local_ip
    except Exception as e:
        return f"Unable to get IP: {e}"



issueTemplate="""There was an issue with the following unit conversion:

| Which unit | User entered input | Parsed input |
| ---------- | ------------------ | ------------ |
| Left unit  | {} | {} |
| Right unit | {} | {} |

### Result/Expectation: 

| Calculation | Result | Expectation | Did match |
|-------------|--------|-------------|-----------|
| Base unit   | {} |     | [ ]       |
| factor * left = right | {} |     | [ ] |
| factor * right = left | {} |     | [ ] |
| factor * left = base | {} |     | [ ] |
| factor * base = left | {} |     | [ ] |
| factor * right = base | {} |     | [ ] |
| factor * base = right | {} |     | [ ] |

If there was an error with the calculation, please fill out the table above. Feel free to also add additional info here: 

*Free text comment*"""
labelOffsetX = [0.0, 0.0, -0.02, 0.07, 0.02, -0.07]
labelOffsetY = [-0.02, 0.09, 0, 0.07, 0, 0.07]
Vanessa Stehr's avatar
Vanessa Stehr committed

class dsiparserInput():
    def __init__(self, defaultInput="", additionalComparisonCallbacks=[]):
        self.additionalComparisonCallbacks = additionalComparisonCallbacks
Benedikt's avatar
Benedikt committed
        self.dsiInput = TextInput(value=defaultInput, title="DSI unit string:", width=500)
Vanessa Stehr's avatar
Vanessa Stehr committed
        self.dsiInput.on_event(ValueSubmit, self.parseInput)
        self.dsiSubmitButton = Button(label="Convert", button_type="primary")
        self.dsiSubmitButton.on_click(self.parseInput)
        self.inputRow = row(children=[self.dsiInput, self.dsiSubmitButton], css_classes=["textInputRow"])
        self.results = column(children=[])
        self.widget = column(children=[self.inputRow, self.results], css_classes=["doubleColumn"])
Benedikt's avatar
Benedikt committed
        self.valideUnit = False
Benedikt's avatar
Benedikt committed
        self.dsiTree = None
Benedikt's avatar
Benedikt committed
    def parseInpuitWithCallbacls(self):
        self.parseInput()
        for callback in self.additionalComparisonCallbacks:
            callback()
Vanessa Stehr's avatar
Vanessa Stehr committed
    def parseInput(self):
Vanessa Stehr's avatar
Vanessa Stehr committed
        self.results.children = []
        input = self.dsiInput.value
        resultTree = dsiUnit(input)
Vanessa Stehr's avatar
Vanessa Stehr committed
        parsingMessages = []
        if resultTree.valid:
            parsingMessages.append(
                Div(
                    text = "DSI string parsed without warnings",
                    css_classes = ["msg-positive"],
                )
            )
        else:
            for message in resultTree.warnings:
                parsingMessages.append(
                    Div(
                        text = message,
                        css_classes = ["msg-negative"]
                    )
                )
        self.resultErrors = column(children = parsingMessages)

        # latexOutput = TextInput(value=resultTree.toLatex(), title="DSI unit string:")
        # latexOutput.js_on_change()
        latexOutput = row(children=[
            Div(text = "$$\mathrm{\LaTeX{}}$$ code:"),
            Div(text = "<pre><code>"+resultTree.toLatex()+"</code></pre>", disable_math=True)
        ])

        p = figure(width=500, height=100, x_range=(0, 1), y_range=(0, 1), tools="save")
        p.xaxis.visible = False
        p.yaxis.visible = False
        p.grid.visible = False

        p.add_layout(Label(
Benedikt's avatar
Benedikt committed
            x=0.5, y=0.5, text=resultTree.toLatex(), text_font_size="30px", text_baseline="middle", text_align="center",
Vanessa Stehr's avatar
Vanessa Stehr committed
        ))

        imageOutput = row(children = [
            Div(text = "$$\mathrm{\LaTeX{}}$$ output:"),
            p
        ], css_classes = ["latexImageRow"])
Vanessa Stehr's avatar
Vanessa Stehr committed
        self.results.children = [self.resultErrors, latexOutput, imageOutput]
        self.dsiTree = resultTree
Benedikt's avatar
Benedikt committed
        if resultTree.valid:
            self.valideUnit = True
Vanessa Stehr's avatar
Vanessa Stehr committed

Benedikt's avatar
Benedikt committed
def removeArrowsAndLabelsFromPlot(plot):
    for location in ['left', 'right', 'above', 'below', 'center']:
        for plotObj in [plot,plot.plot]:
            for layout in plotObj.__getattr__(location):
                if isinstance(layout, Arrow) or isinstance(layout, Label):
                    plotObj.__getattr__(location).remove(layout)

class dsiCompGraphGen:

Benedikt's avatar
Benedikt committed
    def flush(self):
        # Assuming 'plot' is your Bokeh figure
        renderers = self.plot.renderers
        for r in renderers:
            self.plot.renderers.remove(r)
        removeArrowsAndLabelsFromPlot(self.plot)
        removeArrowsAndLabelsFromPlot(self.plot)# only second call removes the three arrows generated last
        self.widget = row(self.plot)
        self.unitLabels = {}
        self.arrows = {}
        self.scalFactorLables = {}

    def __init__(self,treeA=None,treeB=None):
        self.treeA=treeA
        self.treeB=treeB
        self.plot = figure(width=1000, height=500, x_range=(-2, 2), y_range=(0, 1), tools="save")
        self.plot.xaxis.visible = False
        self.plot.yaxis.visible = False
        self.plot.grid.visible = False
Benedikt's avatar
Benedikt committed
        self.flush()
        self.widget=row(self.plot)

    def reDraw(self,treeA=None,treeB=None,complete=False):
        self.treeA=treeA
        self.treeB=treeB
        self.scalfactorAB, self.baseUnit = self.treeA.isScalablyEqualTo(self.treeB,complete=complete)
        self.scalfactorABase, baseUnitABase = self.treeA.isScalablyEqualTo(self.baseUnit,complete=complete)
        self.scalfactorBaseA, baseUnitBaseA = self.baseUnit.isScalablyEqualTo(self.treeA,complete=complete)
        self.scalfactorBaseB, baseUnitbaseB = self.baseUnit.isScalablyEqualTo(self.treeB,complete=complete)
        self.scalfactorBA, baseUnitBA = self.treeB.isScalablyEqualTo(self.treeA,complete=complete)
        self.scalfactorBBase, baseUnitBBase = self.treeB.isScalablyEqualTo(self.baseUnit,complete=complete)
        self.coordinatList = [{'coords':(-0.8, 0.8),'baseUnit':self.treeA,'name':'A','text_baseLine':'middle','text_align':"right"},
                              {'coords': (0.8, 0.8), 'baseUnit': self.treeB,'name':'B','text_baseLine':'middle','text_align':"left"},
                              {'coords': (0.0, 0.2), 'baseUnit': self.baseUnit,'name':'Base','text_baseLine':'top','text_align':"center"},]

        for unitToDraw in self.coordinatList:
            x=unitToDraw['coords'][0]
            y = unitToDraw['coords'][1]
            tree=unitToDraw['baseUnit']
            name=unitToDraw['name']
            if not name in self.unitLabels:
Benedikt's avatar
Benedikt committed
                self.unitLabels[name]=Label(x=x, y=y, text=tree.toLatex(), text_font_size="24px", text_baseline=unitToDraw['text_baseLine'], text_align=unitToDraw['text_align'],)
                self.plot.add_layout(self.unitLabels[name])
            else:
                self.unitLabels[name].text=tree.toLatex()
        colIDX=0
        for quant1, quant2 in itertools.combinations(self.coordinatList, 2):
            x1 = quant1['coords'][0]
            y1 = quant1['coords'][1]
            name1 = quant1['name']
Benedikt's avatar
Benedikt committed

            x2 = quant2['coords'][0]
            y2 = quant2['coords'][1]
            name2 = quant2['name']

            if not name1 + '_' + name2 in self.arrows:
                nh = NormalHead(fill_color=colors[colIDX], fill_alpha=0.5, line_color=colors[colIDX])
                self.arrows[name1 + '_' + name2] = Arrow(end=nh, line_color=colors[colIDX], line_dash=[15, 5], x_start=x1, y_start=y1, x_end=x2, y_end=y2)
                self.plot.add_layout(self.arrows[name1 + '_' + name2])
            scale12, baseUnit = quant1['baseUnit'].isScalablyEqualTo(quant2['baseUnit'], complete=complete)
            Latex = ""
                text = "{:.4g}".format(scale12) + " = " + Latex
                text = "{:.4g}".format(scale12)
            if not name1 + '_' + name2 in self.scalFactorLables:
                angle_deg = -1 * np.arctan2(y2 - y1, x2 - x1)
                if abs(angle_deg) > np.pi / 8:
                    angle_deg += -np.pi / 2
                self.scalFactorLables[name1 + '_' + name2] = Label(x=np.abs(x1 - x2) / 2 + np.min([x1, x2]) + labelOffsetX[colIDX], y=np.abs(y1 - y2) / 2 + np.min([y1, y2]) + labelOffsetY[colIDX], text=text, text_font_size="24px", text_baseline=unitToDraw['text_baseLine'], text_align=unitToDraw['text_align'], text_color=colors[colIDX], angle=angle_deg)
                self.plot.add_layout(self.scalFactorLables[name1 + '_' + name2])
            else:
                self.scalFactorLables[name1 + '_' + name2].text = text

            if not name2 + '_' + name1 in self.arrows:
                nh = NormalHead(fill_color=colors[colIDX + 1], fill_alpha=0.5, line_color=colors[colIDX + 1])
                self.arrows[name2 + '_' + name1] = Arrow(end=nh, line_color=colors[colIDX + 1], line_dash=[15, 5], x_start=x2, y_start=y2 + 0.05, x_end=x1, y_end=y1 + 0.05)
                self.plot.add_layout(self.arrows[name2 + '_' + name1])

            scale21, baseUnit = quant2['baseUnit'].isScalablyEqualTo(quant1['baseUnit'], complete=complete)
            isSpecial = False
            Latex = ""
                text = "{:.4g}".format(scale21) + " = " + Latex
                text = "{:.4g}".format(scale21)
            if not name2 + '_' + name1 in self.scalFactorLables:
                angle_deg = -1 * np.arctan2(y2 - y1, x2 - x1)
                if abs(angle_deg) > np.pi / 8:
                    angle_deg += -np.pi / 2
                self.scalFactorLables[name2 + '_' + name1] = Label(x=np.abs(x1 - x2) / 2 + np.min([x1, x2]) + labelOffsetX[colIDX + 1], y=np.abs(y1 - y2) / 2 + np.min([y1, y2]) + labelOffsetY[colIDX + 1], text=text, text_font_size="24px", text_baseline=unitToDraw['text_baseLine'], text_align=unitToDraw['text_align'], text_color=colors[colIDX + 1], angle=angle_deg)
                self.plot.add_layout(self.scalFactorLables[name2 + '_' + name1])
            else:
                self.scalFactorLables[name2 + '_' + name1].text = text
            colIDX += 2
class page():
    def __init__(self):
        curdoc().template_variables["VERSION"] = VERSION
        curdoc().title = "DSI to Latex"
        curdoc().add_root(bokehCssPTB.getStyleDiv())
        curdoc().theme = bokehCssPTB.getTheme()
        # Tab 1: DSI Parser and Comparator
        self.dsiInput1 = dsiparserInput(defaultInput="\\milli\\newton\\metre", additionalComparisonCallbacks=[self.clearComparison, self.tryComparison])
        self.dsiInput2 = dsiparserInput(defaultInput="\\kilo\\joule", additionalComparisonCallbacks=[self.clearComparison, self.tryComparison])
        self.inputs = row([self.dsiInput1.widget, self.dsiInput2.widget])
        self.comapreButton = Button(label="Compare", button_type="primary")
        self.comapreButton.on_click(self.compare)
        LABELS = ['Complete Comparison']
        self.completeComCBGPR = CheckboxGroup(labels=LABELS, active=[])
        self.compaReresult = Div(text="", css_classes=["msg-positive"], visible=False)
        self.compareRow = row(children=[self.comapreButton, self.completeComCBGPR, self.compaReresult], css_classes=["textInputRow"])
        self.dsiCompGraphGen = dsiCompGraphGen(self.dsiInput1, self.dsiInput2)
        self.createIssueButton = Button(label="Report conversion error", disabled=True)
        tab1_layout = column(self.inputs, self.compareRow, self.dsiCompGraphGen.widget, self.createIssueButton)
        tab1 = TabPanel(child=tab1_layout, title="DSI Parser and Comparator")

        # Tab 2: XML Unit Validator
        self.title = Div(text="<h4>Please upload DCC or other XML containing D-SI Unit Strings here</h4>")
        self.upload_widget = FileInput(accept=".xml")
        self.upload_widget.on_change('value', self.process_xml)
Benedikt's avatar
Benedikt committed
        self.valid_units_message = Div(text="Upload an XML file to validate units", css_classes=["msg-neutral"])
        self.invalid_units_message = Div(text="")
        self.invalid_units_list = GridBox()
Benedikt's avatar
Benedikt committed

        tab2_layout = column(self.title, self.upload_widget, self.valid_units_message, self.invalid_units_message, self.invalid_units_list)
        tab2 = TabPanel(child=tab2_layout, title="XML Unit Validator")


        # Handle URL parameter active tab
        tabs = Tabs(tabs=[tab1, tab2])
        try:
            active_tab = int(curdoc().session_context.request.arguments["active_tab"][0])
        except:
            active_tab = 0
        if active_tab >= len(tabs.tabs):
            active_tab = 0
        tabs.active=active_tab

        curdoc().add_root(tabs)

    def process_xml(self, attr, old, new):
        decoded = b64decode(new).decode('utf-8')
        result = XMLUnitExtractor.parse_and_process(decoded)
        valid_units = result['valid_units']
        invalid_units = result['invalid_units']

        # Update valid units message
        if valid_units:
            self.valid_units_message.text = f"{len(valid_units)} valid units found"
            self.valid_units_message.css_classes = ["msg-positive"]
        else:
            self.valid_units_message.text = "No valid units found"
            self.valid_units_message.css_classes = ["msg-negative"]

Benedikt's avatar
Benedikt committed
        # Update invalid units message
        if invalid_units:
            self.invalid_units_message.text = f"{len(invalid_units)} invalid units found"
            self.invalid_units_message.css_classes = ["msg-negative"]
        else:
            self.invalid_units_message.text = ""
            self.invalid_units_message.css_classes = [""]

        # Update invalid units content
        self.invalid_units_list.children = []
        for row_num, (line_num, details) in enumerate(invalid_units.items()):
            warnings_div = column([Div(text=warning, css_classes=["msg-negative"]) for warning in details.get("warnings", [])])
            unit_row = [
                (Div(text="", css_classes=["divider-hline"]), 2*row_num, 0, 1, 4),
                (Div(text=str(line_num), height_policy="min", css_classes=["msg-neutral"]), 2*row_num+1, 0),
                (Div(text=details["unit"], css_classes=["msg-raw"]), 2*row_num+1, 1),
                (Div(text=f"{dsiUnit(details['unit']).toLatex()}", width=150), 2*row_num+1, 2),
                (warnings_div, 2*row_num+1, 3),
            ]
            self.invalid_units_list.children += unit_row
        self.invalid_units_list.children += [(Div(text="", css_classes=["divider-hline"]), 2*len(invalid_units.items()), 0, 1, 4),]

    def compare(self):
        self.dsiInput1.parseInput()
        self.dsiInput2.parseInput()
        completeConversion = self.completeComCBGPR.active == [0]
Benedikt's avatar
Benedikt committed
        try:
            scalfactor, baseUnit = self.dsiInput1.dsiTree.isScalablyEqualTo(self.dsiInput2.dsiTree, complete=completeConversion)
Benedikt's avatar
Benedikt committed
            if not math.isnan(scalfactor):
                self.compaReresult.text = "The two units are equal up to a scaling factor of " + str(scalfactor) + " and a base unit of " + str(baseUnit)
                self.compaReresult.css_classes = ["msg-positive"]
Benedikt's avatar
Benedikt committed
            else:
                self.compaReresult.text = "The two units are not equal"
                self.compaReresult.css_classes = ["msg-negative"]
            if self.dsiInput1.valideUnit and self.dsiInput2.valideUnit:
                self.dsiCompGraphGen.reDraw(self.dsiInput1.dsiTree, self.dsiInput2.dsiTree, complete=completeConversion)
Benedikt's avatar
Benedikt committed
            else:
                self.dsiCompGraphGen.flush()
        except AttributeError as Ae:
            warnings.warn("AttributeError: " + str(Ae))
            self.compaReresult.text = "The two units are not equal"
            self.compaReresult.css_classes = ["msg-negative"]
Benedikt's avatar
Benedikt committed
            self.dsiCompGraphGen.flush()
        self.compaReresult.visible = True
        self.createIssueButton.disabled = False
        self.createIssueButton.button_type = "danger"
Benedikt's avatar
Benedikt committed
        try:
            #try to update existing callback if callback cant be updatet crate the callback since it wasent initialized
            self.createIssueButton.js_event_callbacks['button_click'][0].update(code=f"window.open('{self.createIssueUrl()}', '_blank');")
        except:
            self.createIssueButton.js_on_event("button_click", CustomJS(code=f"window.open('{self.createIssueUrl()}', '_blank');"))

    def createIssueUrl(self):
        issueArgs = [self.dsiInput1.dsiInput.value, str(self.dsiInput1.dsiTree), self.dsiInput2.dsiInput.value, str(self.dsiInput2.dsiTree)]
        comGenAtrssFroIssue = ['baseUnit', 'scalfactorAB', 'scalfactorBA', 'scalfactorABase', 'scalfactorBaseA', 'scalfactorBBase', 'scalfactorBaseB']
Benedikt's avatar
Benedikt committed
        for comGenAtrss in comGenAtrssFroIssue:
            try:
                issueArgs.append(str(getattr(self.dsiCompGraphGen, comGenAtrss)))
Benedikt's avatar
Benedikt committed
            except AttributeError as Ae:
                issueArgs.append("AttributeError: " + str(Ae))
        filledResult = issueTemplate.format(*issueArgs)
        filledTitle = f'Unexpected comparison result: {self.dsiInput1.dsiInput.value} to {self.dsiInput2.dsiInput.value}'
        issueUrl = r'https://gitlab1.ptb.de/digitaldynamicmeasurement/dsi-parser-frontend/-/issues/new?'
        title = quote(filledTitle)
        body = quote(filledResult)
        url = issueUrl + 'issue[title]=' + title + '&issue[description]=' + body
        return url

Benedikt's avatar
Benedikt committed
    def clearComparison(self):
        self.dsiCompGraphGen.flush()
        self.compaReresult.text = ""
        self.createIssueButton.disabled = True
        self.createIssueButton.button_type = "primary"
        self.compaReresult.visible = False
Benedikt's avatar
Benedikt committed

    def tryComparison(self):
        if self.dsiInput1.valideUnit and self.dsiInput2.valideUnit:
            self.compare()

Vanessa Stehr's avatar
Vanessa Stehr committed
thisPage = page()