- Posts: 88
- Thank you received: 1
Axis Position Logger
- Ehsan_R
- Offline
- Premium Member
-
Less
More
2 years 8 months ago #294402
by Ehsan_R
Replied by Ehsan_R on topic Axis Position Logger
Hello
I am very new to Python programming
Would it be possible for you to give me a more complete explanation of how you did what you did or send me the files you edited?
[url] forum.linuxcnc.org/21-axis/30986-axis-po...gger?start=30#247082 [/url]
I am very new to Python programming
Would it be possible for you to give me a more complete explanation of how you did what you did or send me the files you edited?
[url] forum.linuxcnc.org/21-axis/30986-axis-po...gger?start=30#247082 [/url]
Please Log in or Create an account to join the conversation.
- Ehsan_R
- Offline
- Premium Member
-
Less
More
- Posts: 88
- Thank you received: 1
2 years 8 months ago - 2 years 8 months ago #294406
by Ehsan_R
Replied by Ehsan_R on topic Axis Position Logger
So, to get the source you would download the .deb folder using the wget command as described here:
jethornton.github.io/emclog/install.html
Then right click on the downloaded 'emclog_1.0.0_all.deb' and choose 'Extract Here'
In the extracted folder extract the folder 'data.tar.xz'
You will find the pyhton file 'emclog' in the extracted 'usr/bin' folder.
This file you will need to update to python3.
[edit]
You will also need the gui file 'emclog.ui' from the folder 'emclog_1.0.0_all/usr/lib/python3/dist-packages/libemclog'
I applied these changes
How do I create the installable file after that?
jethornton.github.io/emclog/install.html
Then right click on the downloaded 'emclog_1.0.0_all.deb' and choose 'Extract Here'
In the extracted folder extract the folder 'data.tar.xz'
You will find the pyhton file 'emclog' in the extracted 'usr/bin' folder.
This file you will need to update to python3.
[edit]
You will also need the gui file 'emclog.ui' from the folder 'emclog_1.0.0_all/usr/lib/python3/dist-packages/libemclog'
I applied these changes
How do I create the installable file after that?
Last edit: 2 years 8 months ago by Ehsan_R.
Please Log in or Create an account to join the conversation.
- Aciera
- Away
- Administrator
-
Less
More
- Posts: 4819
- Thank you received: 2177
2 years 8 months ago #294409
by Aciera
Replied by Aciera on topic Axis Position Logger
you can run it from a terminal with (make sure the file 'emclog.py' is marked as executable):
$ python3 <your_path_here>/emclog.py
$ python3 <your_path_here>/emclog.py
The following user(s) said Thank You: Ehsan_R
Please Log in or Create an account to join the conversation.
- Ehsan_R
- Offline
- Premium Member
-
Less
More
- Posts: 88
- Thank you received: 1
2 years 8 months ago #294414
by Ehsan_R
I changed these program lines like this
I got this error
How do I fix it?
Replied by Ehsan_R on topic Axis Position Logger
Code:
#!/usr/bin/python3
import os, sys, math
if sys.version_info[0] > 3:
raise Exception("Python 3 is required.")
Code:
ehsan@debian:~/Desktop$ python3 emclog.py
Traceback (most recent call last):
File "/home/ehsan/Desktop/emclog.py", line 16, in <module>
IN_AXIS = os.environ.has_key("AXIS_PROGRESS_BAR")
^^^^^^^^^^^^^^^^^^
AttributeError: '_Environ' object has no attribute 'has_key'
ehsan@debian:~/Desktop$
How do I fix it?
Please Log in or Create an account to join the conversation.
- Aciera
- Away
- Administrator
-
Less
More
- Posts: 4819
- Thank you received: 2177
2 years 8 months ago #294415
by Aciera
Replied by Aciera on topic Axis Position Logger
Just a few things to get you started, I copied 'emclog.ui' into my home directory and I got the gui started with the following code (start Linuxcnc first):
#! /usr/bin/python3
import os, sys, math
if sys.version_info[0] > 3:
raise Exception("Python 3 is required.")
import configparser
import linuxcnc
from PyQt5 import uic
from PyQt5.QtWidgets import (QApplication, QMainWindow, QCheckBox, QRadioButton,
QMessageBox, QFileDialog, QMenu)
from PyQt5.QtCore import Qt, QTimer, QEvent
IN_AXIS = os.environ["LINUXCNCVERSION"]
if os.path.split(os.environ.get("PYTHONPATH")) == '/usr/bin':
GUI_PATH = '/home/user'
print('Installed')
else:
print (os.path.split(os.environ.get("PYTHONPATH")))
GUI_PATH = '/home/user'
class MainWindow(QMainWindow):
def __init__(self):
super(MainWindow, self).__init__()
self.s = linuxcnc.stat() # create a connection to the status channel
try: # make sure linuxcnc is running
self.s.poll()
except linuxcnc.error:
msg = QMessageBox()
msg.setIcon(QMessageBox.Critical)
msg.setWindowTitle('Error')
msg.setText('LinuxCNC is not running')
msg.setInformativeText('Start LinuxCNC first.')
msg.setStandardButtons(QMessageBox.Ok)
msg.exec_()
exit()
projectPath = os.path.abspath(os.path.dirname(sys.argv[0]))
uic.loadUi(os.path.join(GUI_PATH, 'emclog.ui'), self)
self.qclip = QApplication.clipboard()
self.gcodeLW.installEventFilter(self)
self.axes = [(i) for i in range(9)if self.s.axis_mask & (1<<i)]
self.setupConnections()
self.timer = QTimer()
self.timer.timeout.connect(self.update)
self.timer.start(100)
self.recordTimer = QTimer()
self.recordTimer.timeout.connect(self.log)
self.lastPosition = []
self.dinLog = False
self.dinInput = self.dioSB.value()
self.pref_path = os.path.join(os.path.expanduser("~"), '.emclog_preferences')
self.config = configparser.SafeConfigParser()
self.setupGUI()
self.show()
def eventFilter(self, source, event):
if (event.type() == QEvent.ContextMenu and
source is self.gcodeLW):
contextMenu = QMenu()
deleteLine = contextMenu.addAction('Delete Line')
action = contextMenu.exec_(self.mapToGlobal(event.pos()))
if action == deleteLine:
self.gcodeLW.takeItem(self.gcodeLW.currentRow())
return True
return super(MainWindow, self).eventFilter(source, event)
def setupConnections(self):
self.actionOpen.triggered.connect(self.OpenFile)
self.actionSave.triggered.connect(self.SaveFile)
self.actionSave_As.triggered.connect(self.SaveFileAs)
self.actionSavePreferences.triggered.connect(self.SavePreferences)
self.actionExit.triggered.connect(self.exit)
self.logPB.clicked.connect(self.log)
self.addExtraPB.clicked.connect(self.addExtra)
if IN_AXIS:
self.sendToAxisPB.setEnabled(True)
self.sendToAxisPB.clicked.connect(self.sendToAxis)
self.actionCopy.triggered.connect(self.copy)
self.startPB.clicked.connect(self.record)
self.stopPB.clicked.connect(self.record)
self.dioSB.valueChanged.connect(self.changeInput)
def changeInput(self):
self.dinInput = self.dioSB.value()
def OpenFile(self):
if os.path.isdir(os.path.expanduser('~/linuxcnc/nc_files')):
configsDir = os.path.expanduser('~/linuxcnc/nc_files')
else:
configsDir = os.path.expanduser('~/')
fileName = QFileDialog.getOpenFileName(self,
caption="Select a G code File",
directory=configsDir,
filter='*.ngc',
options=QFileDialog.DontUseNativeDialog,)
if fileName:
self.gcodeLW.clear()
with open(fileName[0], 'r') as f:
for line in f:
self.gcodeLW.addItem(line.strip('\n'))
"""
iniFile = (fileName[0])
print(fileName[0])
with open(fileName[0]) as f:
text = f.read()
self.gCodeViewer.insertPlainText(text)
"""
def SaveFile(self):
if os.path.isdir(os.path.expanduser('~/linuxcnc/nc_files')):
configsDir = os.path.expanduser('~/linuxcnc/nc_files')
else:
configsDir = os.path.expanduser('~/')
fileName, _ = QFileDialog.getSaveFileName(self,
caption="Save G Code",
directory=configsDir,
options=QFileDialog.DontUseNativeDialog)
if fileName:
gcode = '\n'.join(self.gcodeLW.item(i).text() for i in range(self.gcodeLW.count()))
with open(fileName, 'w') as f:
f.write(gcode)
def SaveFileAs(self):
self.SaveFile()
"""
# lets create that config file for next time...
cfgfile = open("c:\\next.ini",'w')
# add the settings to the structure of the file, and lets write it out...
Config.add_section('Person')
Config.set('Person','HasEyes',True)
Config.set('Person','Age', 50)
Config.write(cfgfile)
cfgfile.close()
"""
def SavePreferences(self):
if not os.path.exists(self.pref_path):
self.config.add_section('MAIN')
self.config.set('MAIN','dio', str(self.dioSB.value()))
with open(self.pref_path, 'w') as cfgfile:
self.config.write(cfgfile)
# f.write(str(self.dioSB.value()))
def setupGUI(self):
self.positionCB.addItem('Relative', 'relative')
self.positionCB.addItem('Absolute', 'absolute')
# check axes that are and disable axes that are not
for i in range(9):
getattr(self, 'axisCB_' + str(i)).setChecked(i in self.axes)
if os.path.exists(self.pref_path):
self.config.read(self.pref_path)
self.dioSB.setValue(self.config.getint('MAIN', 'dio'))
def record(self):
if self.startPB.isChecked():
print('Starting {}'.format(self.intervalSB.value()))
timerInterval = self.intervalSB.value() * 1000
self.recordTimer.start(timerInterval)
elif self.stopPB.isChecked():
print('Stopping')
self.recordTimer.stop()
def log(self):
axes = []
for checkbox in self.axesGB.findChildren(QCheckBox): # get axes list
if checkbox.isChecked():
axes.insert(0, str(checkbox.objectName()[-1]))
gcode = []
currentPosition = []
for radio in self.moveGB.findChildren(QRadioButton):
if radio.isChecked(): # add the move type
gcode.append(str(radio.property('gcode')) + ' ')
moveType = str(radio.property('gcode'))
for axis in axes: # add each axis position
axisLetter = str(getattr(self, 'axisCB_' + axis).property('axis'))
position = str(getattr(self, 'positionLB_' + axis).text())
currentPosition.append(float(position))
gcode.append(axisLetter + position)
if moveType in :
if self.arcRadiusLE.text() == '':
self.mbox('{} moves require an arc radius'.format(moveType))
return
if len(self.lastPosition) == 0:
self.mbox('A G0 or G1 move must be done before a {} move'
.format(moveType))
x1 = self.lastPosition[0]
x2 = currentPosition[0]
y1 = self.lastPosition[1]
y2 = currentPosition[1]
if x1 == x2 and y1 == y2:
self.mbox('{} move needs a different end point'.format(moveType))
return
xMid = (x1 + x2) / 2
yMid = (y1 + y2) / 2
slope = (y2 - y1) / (x2 - x1)
distance = math.sqrt(pow((x1 - x2),2) + pow((y1 - y2),2))
radius = float(self.arcRadiusLE.text())
if radius < (distance / 2):
self.mbox('Radius can not be smaller than {0:0.4f}'.format(distance/2))
return
#cosine
c = 1/math.sqrt(1+((slope * -1)*(slope * -1)))
#sine
s = (slope * -1)/math.sqrt(1+((slope * -1)*(slope * -1)))
if moveType == 'G2':
i = xMid + radius * (c)
j = yMid + radius * (s)
gcode.append(' I{0:.{2}f} J{1:.{2}f}'.format(i, j, self.precisionSB.value()))
elif moveType == 'G3':
i = xMid + (-radius) * (c)
j = yMid + (-radius) * (s)
gcode.append(' I{0:.{2}f} J{1:.{2}f}'.format(i, j, self.precisionSB.value()))
if moveType in : # check for a feed rate
feedMatch = self.gcodeLW.findItems('F', Qt.MatchContains)
if len(feedMatch) > 0: # check last feed rate to see if it is different
lastMatch = str(feedMatch[-1].text()).split()
if lastMatch[-1][1:] != self.feedLE.text():
gcode.append(' F{}'.format(str(self.feedLE.text())))
if not self.gcodeLW.findItems('F', Qt.MatchContains):
if self.feedLE.text():
gcode.append(' F{}'.format(str(self.feedLE.text())))
else:
self.mbox('A feed rate must be entered for a {} move'.format(moveType))
return
self.gcodeLW.addItem(''.join(gcode))
self.lastPosition = []
for axis in axes:
self.lastPosition.append(float(getattr(self, 'positionLB_' + axis).text()))
def addExtra(self):
self.gcodeLW.addItem(self.extraLE.text())
def update(self):
self.s.poll()
if self.positionCB.currentData() == 'relative':
# sum the offsets with a negative sign
offsets = tuple(-sum(i) for i in zip(self.s.g5x_offset,self.s.g92_offset))
display = tuple(sum(i) for i in zip(offsets,self.s.actual_position))
else:
display = self.s.actual_position
for i in self.axes:
getattr(self, 'positionLB_' + str(i)).setText('{0:0.{1}f}'.format(display, self.precisionSB.value()))
if self.s.din[self.dinInput] and not self.dinLog:
self.log()
self.dinLog = True
print('remote log')
elif not self.s.din[self.dinInput] and self.dinLog:
self.dinLog = False
def copy(self):
items = []
gcode = [str(self.gcodeLW.item(i).text()) for i in range(self.gcodeLW.count())]
self.qclip.setText('\n'.join(gcode))
def mbox(self, message):
msg = QMessageBox()
msg.setIcon(QMessageBox.Critical)
msg.setWindowTitle('Error')
msg.setText(message)
msg.setStandardButtons(QMessageBox.Ok)
msg.exec_()
def sendToAxis(self):
sys.stdout.write(self.gcodeLW(0, 1))
def exit(self):
exit()
def main():
app = QApplication(sys.argv)
ex = MainWindow()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
Warning: Spoiler!
#! /usr/bin/python3
import os, sys, math
if sys.version_info[0] > 3:
raise Exception("Python 3 is required.")
import configparser
import linuxcnc
from PyQt5 import uic
from PyQt5.QtWidgets import (QApplication, QMainWindow, QCheckBox, QRadioButton,
QMessageBox, QFileDialog, QMenu)
from PyQt5.QtCore import Qt, QTimer, QEvent
IN_AXIS = os.environ["LINUXCNCVERSION"]
if os.path.split(os.environ.get("PYTHONPATH")) == '/usr/bin':
GUI_PATH = '/home/user'
print('Installed')
else:
print (os.path.split(os.environ.get("PYTHONPATH")))
GUI_PATH = '/home/user'
class MainWindow(QMainWindow):
def __init__(self):
super(MainWindow, self).__init__()
self.s = linuxcnc.stat() # create a connection to the status channel
try: # make sure linuxcnc is running
self.s.poll()
except linuxcnc.error:
msg = QMessageBox()
msg.setIcon(QMessageBox.Critical)
msg.setWindowTitle('Error')
msg.setText('LinuxCNC is not running')
msg.setInformativeText('Start LinuxCNC first.')
msg.setStandardButtons(QMessageBox.Ok)
msg.exec_()
exit()
projectPath = os.path.abspath(os.path.dirname(sys.argv[0]))
uic.loadUi(os.path.join(GUI_PATH, 'emclog.ui'), self)
self.qclip = QApplication.clipboard()
self.gcodeLW.installEventFilter(self)
self.axes = [(i) for i in range(9)if self.s.axis_mask & (1<<i)]
self.setupConnections()
self.timer = QTimer()
self.timer.timeout.connect(self.update)
self.timer.start(100)
self.recordTimer = QTimer()
self.recordTimer.timeout.connect(self.log)
self.lastPosition = []
self.dinLog = False
self.dinInput = self.dioSB.value()
self.pref_path = os.path.join(os.path.expanduser("~"), '.emclog_preferences')
self.config = configparser.SafeConfigParser()
self.setupGUI()
self.show()
def eventFilter(self, source, event):
if (event.type() == QEvent.ContextMenu and
source is self.gcodeLW):
contextMenu = QMenu()
deleteLine = contextMenu.addAction('Delete Line')
action = contextMenu.exec_(self.mapToGlobal(event.pos()))
if action == deleteLine:
self.gcodeLW.takeItem(self.gcodeLW.currentRow())
return True
return super(MainWindow, self).eventFilter(source, event)
def setupConnections(self):
self.actionOpen.triggered.connect(self.OpenFile)
self.actionSave.triggered.connect(self.SaveFile)
self.actionSave_As.triggered.connect(self.SaveFileAs)
self.actionSavePreferences.triggered.connect(self.SavePreferences)
self.actionExit.triggered.connect(self.exit)
self.logPB.clicked.connect(self.log)
self.addExtraPB.clicked.connect(self.addExtra)
if IN_AXIS:
self.sendToAxisPB.setEnabled(True)
self.sendToAxisPB.clicked.connect(self.sendToAxis)
self.actionCopy.triggered.connect(self.copy)
self.startPB.clicked.connect(self.record)
self.stopPB.clicked.connect(self.record)
self.dioSB.valueChanged.connect(self.changeInput)
def changeInput(self):
self.dinInput = self.dioSB.value()
def OpenFile(self):
if os.path.isdir(os.path.expanduser('~/linuxcnc/nc_files')):
configsDir = os.path.expanduser('~/linuxcnc/nc_files')
else:
configsDir = os.path.expanduser('~/')
fileName = QFileDialog.getOpenFileName(self,
caption="Select a G code File",
directory=configsDir,
filter='*.ngc',
options=QFileDialog.DontUseNativeDialog,)
if fileName:
self.gcodeLW.clear()
with open(fileName[0], 'r') as f:
for line in f:
self.gcodeLW.addItem(line.strip('\n'))
"""
iniFile = (fileName[0])
print(fileName[0])
with open(fileName[0]) as f:
text = f.read()
self.gCodeViewer.insertPlainText(text)
"""
def SaveFile(self):
if os.path.isdir(os.path.expanduser('~/linuxcnc/nc_files')):
configsDir = os.path.expanduser('~/linuxcnc/nc_files')
else:
configsDir = os.path.expanduser('~/')
fileName, _ = QFileDialog.getSaveFileName(self,
caption="Save G Code",
directory=configsDir,
options=QFileDialog.DontUseNativeDialog)
if fileName:
gcode = '\n'.join(self.gcodeLW.item(i).text() for i in range(self.gcodeLW.count()))
with open(fileName, 'w') as f:
f.write(gcode)
def SaveFileAs(self):
self.SaveFile()
"""
# lets create that config file for next time...
cfgfile = open("c:\\next.ini",'w')
# add the settings to the structure of the file, and lets write it out...
Config.add_section('Person')
Config.set('Person','HasEyes',True)
Config.set('Person','Age', 50)
Config.write(cfgfile)
cfgfile.close()
"""
def SavePreferences(self):
if not os.path.exists(self.pref_path):
self.config.add_section('MAIN')
self.config.set('MAIN','dio', str(self.dioSB.value()))
with open(self.pref_path, 'w') as cfgfile:
self.config.write(cfgfile)
# f.write(str(self.dioSB.value()))
def setupGUI(self):
self.positionCB.addItem('Relative', 'relative')
self.positionCB.addItem('Absolute', 'absolute')
# check axes that are and disable axes that are not
for i in range(9):
getattr(self, 'axisCB_' + str(i)).setChecked(i in self.axes)
if os.path.exists(self.pref_path):
self.config.read(self.pref_path)
self.dioSB.setValue(self.config.getint('MAIN', 'dio'))
def record(self):
if self.startPB.isChecked():
print('Starting {}'.format(self.intervalSB.value()))
timerInterval = self.intervalSB.value() * 1000
self.recordTimer.start(timerInterval)
elif self.stopPB.isChecked():
print('Stopping')
self.recordTimer.stop()
def log(self):
axes = []
for checkbox in self.axesGB.findChildren(QCheckBox): # get axes list
if checkbox.isChecked():
axes.insert(0, str(checkbox.objectName()[-1]))
gcode = []
currentPosition = []
for radio in self.moveGB.findChildren(QRadioButton):
if radio.isChecked(): # add the move type
gcode.append(str(radio.property('gcode')) + ' ')
moveType = str(radio.property('gcode'))
for axis in axes: # add each axis position
axisLetter = str(getattr(self, 'axisCB_' + axis).property('axis'))
position = str(getattr(self, 'positionLB_' + axis).text())
currentPosition.append(float(position))
gcode.append(axisLetter + position)
if moveType in :
if self.arcRadiusLE.text() == '':
self.mbox('{} moves require an arc radius'.format(moveType))
return
if len(self.lastPosition) == 0:
self.mbox('A G0 or G1 move must be done before a {} move'
.format(moveType))
x1 = self.lastPosition[0]
x2 = currentPosition[0]
y1 = self.lastPosition[1]
y2 = currentPosition[1]
if x1 == x2 and y1 == y2:
self.mbox('{} move needs a different end point'.format(moveType))
return
xMid = (x1 + x2) / 2
yMid = (y1 + y2) / 2
slope = (y2 - y1) / (x2 - x1)
distance = math.sqrt(pow((x1 - x2),2) + pow((y1 - y2),2))
radius = float(self.arcRadiusLE.text())
if radius < (distance / 2):
self.mbox('Radius can not be smaller than {0:0.4f}'.format(distance/2))
return
#cosine
c = 1/math.sqrt(1+((slope * -1)*(slope * -1)))
#sine
s = (slope * -1)/math.sqrt(1+((slope * -1)*(slope * -1)))
if moveType == 'G2':
i = xMid + radius * (c)
j = yMid + radius * (s)
gcode.append(' I{0:.{2}f} J{1:.{2}f}'.format(i, j, self.precisionSB.value()))
elif moveType == 'G3':
i = xMid + (-radius) * (c)
j = yMid + (-radius) * (s)
gcode.append(' I{0:.{2}f} J{1:.{2}f}'.format(i, j, self.precisionSB.value()))
if moveType in : # check for a feed rate
feedMatch = self.gcodeLW.findItems('F', Qt.MatchContains)
if len(feedMatch) > 0: # check last feed rate to see if it is different
lastMatch = str(feedMatch[-1].text()).split()
if lastMatch[-1][1:] != self.feedLE.text():
gcode.append(' F{}'.format(str(self.feedLE.text())))
if not self.gcodeLW.findItems('F', Qt.MatchContains):
if self.feedLE.text():
gcode.append(' F{}'.format(str(self.feedLE.text())))
else:
self.mbox('A feed rate must be entered for a {} move'.format(moveType))
return
self.gcodeLW.addItem(''.join(gcode))
self.lastPosition = []
for axis in axes:
self.lastPosition.append(float(getattr(self, 'positionLB_' + axis).text()))
def addExtra(self):
self.gcodeLW.addItem(self.extraLE.text())
def update(self):
self.s.poll()
if self.positionCB.currentData() == 'relative':
# sum the offsets with a negative sign
offsets = tuple(-sum(i) for i in zip(self.s.g5x_offset,self.s.g92_offset))
display = tuple(sum(i) for i in zip(offsets,self.s.actual_position))
else:
display = self.s.actual_position
for i in self.axes:
getattr(self, 'positionLB_' + str(i)).setText('{0:0.{1}f}'.format(display, self.precisionSB.value()))
if self.s.din[self.dinInput] and not self.dinLog:
self.log()
self.dinLog = True
print('remote log')
elif not self.s.din[self.dinInput] and self.dinLog:
self.dinLog = False
def copy(self):
items = []
gcode = [str(self.gcodeLW.item(i).text()) for i in range(self.gcodeLW.count())]
self.qclip.setText('\n'.join(gcode))
def mbox(self, message):
msg = QMessageBox()
msg.setIcon(QMessageBox.Critical)
msg.setWindowTitle('Error')
msg.setText(message)
msg.setStandardButtons(QMessageBox.Ok)
msg.exec_()
def sendToAxis(self):
sys.stdout.write(self.gcodeLW(0, 1))
def exit(self):
exit()
def main():
app = QApplication(sys.argv)
ex = MainWindow()
sys.exit(app.exec_())
if __name__ == "__main__":
main()
The following user(s) said Thank You: Ehsan_R
Please Log in or Create an account to join the conversation.
- Aciera
- Away
- Administrator
-
Less
More
- Posts: 4819
- Thank you received: 2177
2 years 8 months ago #294416
by Aciera
Replied by Aciera on topic Axis Position Logger
Note that this modification is not correct
but the original is also not working:
Code:
[i] def sendToAxis(self):
sys.stdout.write(self.gcodeLW(0, 1))[/i]
but the original is also not working:
Code:
def sendToAxis(self):
sys.stdout.write(self.g_code.get(0.0, END))
Please Log in or Create an account to join the conversation.
Time to create page: 0.168 seconds