| Category: Particle | |||
|---|---|---|---|
SubjectPython and pyROOT Tutorial | |||
ContentOutlineThis tutorial serves as a generic introduction to python and a brief introduction to the high-energy physics analysis package The root manual and reference guide: https://root.cern/manual/ Please email itsupport@physics.ox.ac.uk with any errors. 1) What is python?From the python website:
Breaking this down:
2) Python vs c++Normally, start by using python unless you need to use C++ for some reason. Those reasons include:
Consider two programs, one in C++ and one in python //A C++ Program called hello.cpp #include "iostream" int main(int argc, char** argv) { std::cout<<"Hello World" << std::endl; return 0; } demo@pplxint8 > g++ hello.cpp -o hello demo@pplxint8 > ./hello Hello World #A program written in python #!/usr/bin/python print "Hello World" demo@pplxint8 > python ./hello.py There is often a lot less to type when writing a python program or script. There are fewer steps to remember and get wrong (write and run vs write, compile and run). Perhaps most important when it comes to finding errors is the clarity of python code. 3) Running the python interpreterPython's main power comes with how easy it is to get something that works. Part of that power is the rapid prototyping that is made possible by the interpreter. Python comes with a native interpreter that can be used simply by running:
We can quit with Ctrl+D. However, the basic interpreter lacks a few features. A better one is "ipython". Python has a few built in types, such as your usual ints, floats. It also naively supports quite complex strings and string modification functions. Lets start with something simple to demonstrate using the the variable "aString" to store the text "hello world": ipython > print 'hello world'
hello world
ipython > aString='hello world'
ipython > print aString
hello world The interpreter also offers standard "shell-like" features such as tab completion and history - try tab completion by typing "aSt" in the example above and then tab before pressing enter. In addition, the history buffer can be scrolled by pressing up/down arrows or searched with 'Ctrl+R', starting to type the command, e.g. the first part, "hell" of "hello" in the example above and then Ctrl+R to scroll through the matches. To create runnable reusable code, you should write your code to a '.py' file. After prototyping with "ipython", you can copy the inputs that you made into the console out to a .py file using the save magic function. The syntax is 'save {file-name} {line-numbers}'. > ipython In [1]:print 'hello world' In [2]:save 'mySession' 0-1 In [3]:Ctrl+D > cat mySession.py # coding: utf-8 print 'hello world' You can then re-run your old code with: python ./mySession.py
If you are running on a machine integrated with a batch system such as pplxint8/9, you can scale up and out your python session onto the batch nodes. This would be achieved by adding " Python types often come with their own documentation. This can be accessed using help(int)
dir(int)
int?
iMyInt=100
help(iMyInt)
dir(iMyInt)
sMyInt? 4) Basic SyntaxPython uses white-space to delineate code blocks or 'scopes' Anything indented to the same degree is considered to be part of the same scope. Variables defined within an indented scope can persist after the end of the scope except for in new class and function scopes. Some statements require that a new scope is opened after they are used - we will introduce some of these later. Comments in python are denoted with a hash ('#') symbol. The text between the hash symbol and the end of the current line is not treated as code. Instead it is used to provide a hint to people reading your code as to what it does. As a rule of thumb, about 1/3 of the text in your python programs should normally be in comments. Variable types (eg int, float) are rarely explicitly specified in python except when they are first defined. Python will decide for you what type a variable is. We say that the variables in python are not strongly typed (however the values are). To create a new variable, just write a name that you want to refer to it by and then use the assign (=) operator and give it a value. E.g to assign the value '2' to the identifier 'myNumber', simply write 'mynumber = 2'. You can then use myNumber anywhere in your code and the interpreter will treat every occurrence just as if you had directly written the number '2'. Unlike some languages, no type needs to be specified and no special characters are needed to indicate that you want the variable to be expanded to it's value. 5) Built-in Types and simple functionsPython has a range of built in types and has powerful introspection features. In python it is rare to specify the type of a variable directly. Instead, just let python decide. Python function calls, similar to other languages, use parenthesis "()" to indicate the function. If the function takes arguments, the arguments go into the parenthesis separated by commas. The type() function can be used to determine the type at run-time. Some examples are below. In [33]: iX=1 In [34]: type(iX) Out[34]: int In [35]: fY=1.0 In [36]: type(fY) Out[36]: float In [37]: sZ='string' In [38]: type(sZ) Out[38]: str In [39]: uUniExample=u'string' In [40]: type(uUniExample) Out[40]: unicode In [41]: lListExample=[1,'a'] In [42]: type(lListExample) Out[42]: list In [43]: dDict={'a', 'b', 4} In [44]: type(dDict) Out[44]: dict In [43]: bBool=True In [44]: type(bBool) Out[44]: bool It is also easy change the type of a variable just by reassigning a new variable of a different type, e.g. In [45]: s='string'
In [46]: i=1
In [47]: a=b
In [48]: type(a)
Out[48]: int
Debugging tip: This ability to change the type of a variable at run-time is a powerful feature, but it can also be dangerous and its misuse has in the past actually doubled the amount of time we spent working on one project. When the type() function is scattered into your debugging statements together with a convention of storing the variable type somewhere in the variable name, one can often spot errors caused by passing the wrong type of variable to a function more quickly. When the type should absolutely not change, I choose to use the first letter of the variable name to indicate its type - see code lines 33-44 above for examples. 6) ContainersListsPython's most basic conventional container is a list. A list may contain mixed parameter types and may be nested. Elements within the list can be accessed by using the integer corresponding to that element's position in the list, starting from '0'. In [1]: d=[1, 2, 3, 'Last one' ]
In [2]: print d
[1, 2, 3, 'Last one']
In [3]: print d[0]
1
In [5]: e=[1, ['a', 'b', 'c' ] ]
In [4]: print e[1][1]
b
We can also re-assign elements of the list, and add to the list with the In [1]: f=[1, 2, ['a', 'b', 'c' ], 'Last one' ]
In [2]: len(f)
Out[2]: 4
In [2]: f[3]='d'
In [3]: len(f)
Out[3]: 4
In [4]: f.append('e')
In [5]: print d
Out [5]:f=[1, 2, ['a', 'b', 'c' ], 'd', 'e' ]
In [6]: len(f)
Out[6]: 5
The "append" function illustrates "member functions". Member functions of classes modules can be accessed by giving the class instance name or module name post-fixed by a dot and then the member function or variable: e.g. You can use either double quotes or single quotes to enclose a string. You can use this fact to include quotes in your string, e.g.
More on stringsIn python, a string is really a sequence container. It supports the "len()" and index "[]" operations like the list above. In [12]: sMyString='0123456789a' In [13]: len(sMyString) Out[13]: 11 In [14]: sMyString[2] Out[14]: '2' What do you think the following code will do? In [1]: sString='10'
In [2]: print sString*3 Though the variables are not strongly typed, the values are. The operator (*) is a duplicate operator for the string. DictionaryThe python dictionary class is more powerful than the list type. You can use any type as the index, not just an integer. In the example below, we emulate the above list using a dict then add on an element using a In [15]: dMyDict={ 0:1, 1: 2, 2: ['a', 'b', 'c'], 3: 'Last one'} In [16]: dMyDict Out[16]: {0: 1, 1: 2, 2: ['a', 'b', 'c'], 3: 'Last one'} In [17]: len(dMyDict) Out[17]: 4 In [18]: dMyDict[4]='e' In [29]: print dMyDict {0: 1, 1: 2, 2: ['a', 'b', 'c'], 3: 'Last one', 4: 'e'} In [20]: len(dMyDict) Out[20]: 5 In [21]: dMyDict['arbitrary_type']='another_type' In [22]: dMyDict Out[22]: {0: 1, 1: 2, 2: ['a', 'b', 'c'], 3: 'END', 4: 'e', 'arbitrary_type': 'another_type'} 7) Loops and conditionals"if" conditionalThe syntax for a python if statement is: if 1 == a:
#do something
#else if does not work in python, use elif
elif 1 == b:
#do something else
else:
#do this thing LoopsLoops in python can be controlled by the for and while loops. The Here is an example of both for and while loops: ###1-Control demo 1 in the source code attached### l=['a','b','c'] count=0 while count < 3: print l[count], ' is l from while loop' count=count+1 for val in l: print val, ' is l from for loop'
The continue statement tells the code to jump straight to the next iteration of the loop and stop executing any more code in this iteration. The break statement jumps straight out of the loop and executes no further code in the for or while loop. Notice also I have introduced the 'not' keyword, which negates the expression it prefixes. I have also introduced the rather standard shorthand for ###1-Control demo 2 in the source code attached####
#!/usr/bin/python
l=['a','b','c']
count=0
very_interesting='b'
while count < 4:
print l[count]
if l[count] not very_interesting:
count+=1
continue
print 'I have found the interesting number'
#Stop executing code here
break 8) File I/OA common problem is that a text file containing data formatted in a certain way is given to you as input data. Your job will need to parse and analyse this datafile. #open source file for reading
source = open('data.txt','r')
#open dest file for writing
dest = open(destfile.txt','w')
#loop over all the lines in the first file
#and write both to the screen and to the destination file
for line in source:
#Print to the screen
print line
#Write to the file
dest.write(line)
#Always close the files
source.close()
dest.close() Now see demo3 in the attached source code 9) Writing functionsIt is advisable to constantly review your code and divide it up into re-usable functions where possible. The #!/usr/bin/python ###Function demo 1#### #First define the function that will be used def myFirstFunction(myParam1, myParam2='default'): #Indent 4 spaces print myParam1 #Check whether the second parameter is the default one or not if 'default' == myParam2: #Indent 4 spaces print 'I was not passed a second parameter' else: print 'I was passed a second parameter, it was: ', myParam2 #Return a string saying everything went well #Note, convention is to return the integer '0' if the function behaved as expected #or a non-zero integer if there was an error. return 'OK' # no indent # the first non-indented (or commented) line without the def or class keyword is the # entry point of the code print 'About to run my function' myFirstFunction('I am running in a function!!') retval=myFirstFunction('Passing Another Parameter','"this text"') print '"myFirstFunction" returned "', retval, '"' Now see demo2 in the attached source code 10) KeywordsSo far we have covered most of what is needed to write a simple program, and it is beyond the scope of this introduction to cover all python reserved words. However, a list of the python reserved keywords is given here for completeness so that the reader can look up these online. The following identifiers are used as reserved words, or keywords of the language, and cannot be used as ordinary identifiers. They must be spelled exactly as written here: and del for is raise
assert elif from lambda return
break else global not try
class except if or while
continue exec import pass yield
def finally in print Note that although the identifier In some future version of Python, the identifiers 11) Modules and passing command line arguments to pythonPython has a lot of built-in functionality, but that is nothing compared to what exists in the extensions or 'modules' than have been written for python. I cannot tell you how to use them all, or even what ones are available. Try googling what you want to do, and usually you will find a module to do it. Once you know which module you want, fire up the (i)python interpreter and type The modules often come with their own documentation. This can be accessed using To use some feature of the module, give the module name followed by the function, class or variable that the module provides with a dot in between. The sys moduleA heavily used module is the 'sys' module, which is most often used to pass information from the calling shell to python and vice versa. #!/usr/bin/python
###Module demo 1###
import sys
#print out the help documentation for sys - there is a lot!
help(sys) #!/usr/bin/python ###Module demo 2### import sys #get the number of parameters passed to the python program argc=len(sys.argv) #Loop over the arguments passed to the program and print them out. #Note that the first argument is always the name of the running program. for arg in sys.argv: print arg #Exit with a status of '0' to indicate to the shell that the python program ran OK. sys.exit(0) You should also familiarise yourself with the "os" module, which is also used a lot. Now see demo4 in the attached source code 12) Extending python with additional modules and running newer python versionsPython has a feature called virtual environments. This allows you to build on system versions of python to do the following:
To create a virtualenv called myVirtualEnv, do the following. cd $HOME
virtualenv --system-site-packages myVirtualenv
. $HOME/myVirtualenv/bin/activate
pip install --upgrade pip setuptools Every time you wish to use the virtualenv in the future, run just the following. . $HOME/myVirtualenv/bin/activate At this point, you will notice your prompt change to indicate you are running in a virtualenv. You can install new python modules into the virtual environment. Lets now look for and install a package to help with plotting in numpy pip search numpy
pip install numpy I can now run some numpy code ipython
In [1]: import numpy
In [2]: import matplotlib.pyplot as pt
In [4]: a=numpy.array([1,2,3,4,5])
In [5]: line=pt.plot(a,a)
In [6]: pt.show()
More information on numpy and pyplot can be found here Running newer versions of python in your virtualenvOn most university and research lab systems, IT will be able to install a new version of python if requested, so I wont cover that here. To make use of these versions of python all you need is the path to the new python version. For example in physics. module load python/2.7
virtualenv --system-site-packages -p `which python` ~/myPython27Venv Running python3 virtualenvsFor python 3, virtualenvs have been replaced. Some of the early versions of python 3 had various issues, but from python 3.4.5 things seem very smooth with the new tool "pyvenv". It is very similar to virtualenvs. To set up a python 3.4.5 pyvenv: module load python/3.4.5
pyvenv-3.4 myVirtualenv2
. myVirtualenv2/bin/activate
pip install --upgrade pip setuptools
pip install scipy To load it again for re-use: module load python/3.4.5
. myVirtualenv2/bin/activate Multiple simultaneous environments (advanced)You can layer multiple virtualenvs on top of each other. Lets say for example you want to use the system packages, and a standard set of additional packages maintained by your experiment followed by a few updates you are testing yourself. To prepare such an environment, there is the python package called "envplus". An example below shows how to import the virtualenv we prepared above for another user so that numpy is pre-installed. In a current writable directory: #Note, in this case ensure that you can get the defaults from the system if they exist with --system-site-packages
virtualenv --system-site-packages layeredenv
. $HOME/layeredenv/bin/activate
pip install envplus
export WORKON_HOME=$HOME/layeredenv
export EDITOR=vim
envplus add /network/group/particle/itstaff/numpy_virtualenv Following that, its just the same as before . layeredenv/bin/activate
pip install YOURMODULE
ipython
import numpy 13) pyROOT - for PP students onlyUp to now, this tutorial has been quite generic. This part is for particle physics students. Some helpful chap has wrapped up the entirety of ROOT into a python module. Since python syntax is more natural than C++ and the python interpreter does not suffer from as many bugs and problems with non-standard syntax as the 'CINT' interpreter, I recommend using pyROOT instead of CINT when you are starting any program from scratch. People may suggest that python is slower than C++. It is, but that statement applies to compiled C++ not CINT, for the most part it doesn't matter and also you need to know C++ well to make it very fast. At the end of the day, any really slow parts of your code can be re-written in C(++) if absolutely necessary. Remember two generalizations about C++ and general execution times. 1) The average C++ software engineer writes 6 lines of useful, fully tested and debugged code per day. Lets begin by setting up root. To use pyROOT, the C++ libraries must be on your PYTHONPATH. This is set up automatically by the most recent Oxford set-up scripts. pplxint8.physics.ox.ac.uk%> module load root
pplxint8.physics.ox.ac.uk%> echo $PYTHONPATH In the case of pyROOT a deliberate decision was made by the developers not to import all of the symbol names when import is run. Only after the symbol is first used is it available to the interpreter (e.g. via tab-complete or help()). Lets now import the ROOT module into root and use it to draw a simple histogram. In [1]: import ROOT In [2]: ROOT.gROOT.Reset() In [3]: dir(ROOT) Out[3]: ['PyConfig', '__doc__', '__file__', '__name__', 'gROOT', 'keeppolling', 'module'] In [4]: x=TH1F() In [5]: dir(ROOT) Out[5]: ['AddressOf', 'MakeNullPointer', 'PyConfig', 'PyGUIThread', 'SetMemoryPolicy', 'SetOwnership', 'SetSignalPolicy', 'TH1F', 'Template', '__doc__', '__file__', '__name__', 'gInterpreter', 'gROOT', 'gSystem', 'kMemoryHeuristics', 'kMemoryStrict', 'kSignalFast', 'kSignalSafe', 'keeppolling', 'module', 'std']
Debugging tip: If you are changing the version of python (sometimes implicit when altering your root version) you may want to eithe re-create your ipython profile ( To use root in the ipython interpreter and create, fill and draw a basic histogram: In [1]: import ROOT, time In [1]: ROOT.gROOT.Reset() In [2]: hist=ROOT.TH1F("theName","theTitle;xlabel;ylab",100,0,100) In [3]: hist.Fill(50) Out[3]: 51 In [4]: hist.Fill(50) Out[4]: 51 In [5]: hist.Fill(55) Out[5]: 56 In [6]: hist.Fill(45) Out[6]: 46 In [7]: hist.Fill(47) Out[7]: 48 In [8]: hist.Fill(52) Out[8]: 53 In [9]: hist.Fill(52) Out[9]: 53 In [10]: hist.Draw() Info in <TCanvas::MakeDefCanvas>: created default TCanvas with name c1 In [11]: save 'rootHist' 1-10 In [12]: ctrl+D pplxint9.physics.ox.ac.uk%> echo '#Sleep for 10 secs as a way to view the histogram before the program exits'>> rootHist.py pplxint9.physics.ox.ac.uk%> echo 'time.sleep(10)'>> rootHist.py pplxint9.physics.ox.ac.uk%> python rootHist.py You can view the contents of rootHist.py using an editor like "emacs". To fill an ntuple/Tree import ROOT ROOT.gROOT.Reset() # create a TNtuple ntuple = ROOT.TNtuple("ntuple","ntuple","x:y:signal") #store a reference to a heavily used class member function for efficiency ntupleFill = ntuple.Fill # generate 'signal' and 'background' distributions for i in range(10000): # throw a signal event centred at (1,1) ntupleFill(ROOT.gRandom.Gaus(1,1), # x ROOT.gRandom.Gaus(1,1), # y 1) # signal # throw a background event centred at (-1,-1) ntupleFill(ROOT.gRandom.Gaus(-1,1), # x ROOT.gRandom.Gaus(-1,1), # y 0) # background ntuple.Draw("y") Instead of launching the script from the Linux command line, it is possible to run the script from within the "ipython" interpreter and keep all your variables so that you can continue to work. shell> ipython
%run root-hist.py Also, a feature of the root module means that the ntuple and the new canvas appears in the ROOT name-space for you to continue using it in your program too. ROOT.c1
ROOT.ntuple
hist=ROOT.ntuple.GetHistogram()
hist.GetNbinsX() You can also find your objects again using the TBrowser. t=ROOT.TBrowser() The GUI that pops up has a number of pseudo-directories to look in. Open the one that says 'root' in the left pane and navigate to ROOT Memory-->ntuple. You can double click on your histograms to draw them from here. By right-clicking on the name of your ntuple (in this case just 'ntuple') and navigating to 'Start Viewer', you can drag and drop the variables to draw as a histogram or apply as a cut. Drag signal into the box marked with scissors and x onto the 'x' box. Click the purple graph icon to Draw. You may have to create the canvas first. You can do that from the new TBrowser - See the "Command" box in figure 1.
Filling an ntupleThe attached source code contains a workable demonstration of working with ROOT ntuples, covering: Now work through demo 5 in the attached source code Garbage collection (specific to ROOT but not specific to pyROOT)Normally, garbage collection is classed as an advanced concept, however in my experience most of the annoyance of ROOT in general was to do with seemingly random crashes. Most of these were actually due to the object I was using disappearing at various point in the program. This was due to misunderstanding ROOT's object ownership model, which functions as a poor-mans garbage collection. This happens outside of python. Root objects are not persistent. They are owned by the directory they "exist" within. In this case the histogram is actually owned by the canvas (which is itself a directory in ROOT), but the ntuple only contains a reference to it. TDirectory and TPad classes and derived classes count as directories in this model. htemp=ROOT.ntuple.GetHistogram()
htemp2=ROOT.c1.GetListOfPrimitives()[1]
if htemp==htemp2:
print 'We have two references to the same object'
#Draw the histogram, all is fine
htemp.Draw()
##At this point we elect to close the canvas. The canvas disappears and is deleted.
##The canvas owns the histogram drawn within, which also gets deleted.
htemp.Draw()
##Causes an error, How did that happen? One way to rescue the situation, if you want the histogram to outlive the canvas you can make a copy: ....
hpersist=ROOT.c1.GetListOfPrimitives()[1].Clone()
###Now close the canvas
hpersist->Draw()
###Draws a nice histogram or you can remove the histogram from the pad before you close it htemp=ROOT.ntuple.GetHistogram()
##Remove the histogram from the list of objects owned by c1
##Try moving the histogram inside canvas around (to force a re-draw). It disappears.
ROOT.c1.GetListOfPrimitives().Remove(htemp)
###Now close the canvas
htemp.Draw()
##Don't forget to remove the histogram from the list of canvas primitives before closing the next canvas!
print htemp Chapter 8 of the ROOT manual details more on object ownership. 14) Additional Resources: 15) Optimization tricksThe most important thing is to do good Physics, fast computer code comes later. However, since I am in charge of looking after the batch systems, I have a vested interest in your code not crippling the entire computing system. This exercise also gives more practice importing and using someone elses modules (profiler and stats). There is one place where python is very slow and there is an easy fix. That is when running code in a loop. This section will demonstrate that slowdown and give a solution to the problem. Generally only optimize where it is needed. Even the world experts can't guess where this will be needed 100% of the time. To find what is taking the time, a little tool called cProfile exists. Run this example program to see how it works. Spend the time looking only at optimizing the functions that are taking time. I have two functions, foo and bar. I want to know which to optimize. From the output, it looks like import cProfile #Define 3 functions that do something. In this case the content of the functions is not the relevant thing. def add(a): a+=1 def bar(): print 'y' for x in range(2000000): add(x) def foo(): a=0 for i in range(10): print i for j in range(100000): a+=j #calling bar from within foo bar() #The point of this exercise is the profiler. Declare a new profiler called 'fooprof' which will immediately run the foo() function like this: cProfile.run('foo()','fooprof') #To output the results of the profiler, we need pstats import pstats pf = pstats.Stats('fooprof') #Print out the sorted stats print pf.sort_stats(-1).print_stats() Common cause of python slowdownsThere is a quick way to speed up python code to play a trick when repeatedly using a class member function. Store the class member function function in a variable and use that to make the function call. In the example which follows, the class member function is Note, this section is about optimizing a function being called repeatedly in a loop. The function could have been doing anything, it just so happends I chose addition. This section is not about optimizing addition. import cProfile class adder: def add(self, x): x+=1 def add(x): x+=1 def foo(): print 'y' myadder=adder() #take a reference to the class member function adderadd=myadder.add for x in range(200000): #Fastest is to use a function without the class add(x) #slowest is to make python look up the class and function every time myadder.add(x) #Almost the fastest, python no longer needs to look up where the class member function 'is' each time as the result of that lookup is cached (in adderadd). adderadd(x) cProfile.run('foo()','fooprof') import pstats p = pstats.Stats('fooprof') print p.sort_stats(-1).print_stats() We learn from this that by creating a local reference to a class member function for our innermost loops, we can speed up python dramatically. For more tips, see https://wiki.python.org/moin/PythonSpeed/PerformanceTips. #SLOW for i in range(10000): myclass.foo() #FAST mcfoo=myclass.foo for i in range(10000): mcfoo() All of this is available in demo 6 in the attached source code 16) Ipython notebooksIpython notebooks allows you to run python in a web-browser and save your sessions. You can connect from the interactive machine on which you are running and any graphics will pop up on your X display (i.e. not in the browser). As with all web applications, anyone connecting to the application can do whatever you can do. To avoid this, there is some additional set-up to do, which you should do if you want to make it hard for anyone connected to the machine to delete all of your files (for example). So set a password on the browser. This is probably safe enough for use on the interactive machines if you connect from the firefox webbrowser that is installed on the machine on which you run the notebook. Make a little python script that does this: ##############makepass.py############ Create an ipython profile to include the settings for the notebook. > ipython profile create default You may need to remove the existing ipython configuration that you have with >rm -fr ~/.ipython Add the following line to
Where Be absolutely sure nobody can read this file: You can then run the notebook (on pplxint8) with: > module load root/5.34.09_gcc4.6
> module load zeromq/3.2.4
> ipython notebook jupyterAn improvement on python notebooks is 'jupyter'. I found this difficult to install, but works in python3. To use jupyter: module load python/3.4.4
pyvenv jupytertest
. jupytertest/bin/activate
pip3 install --upgrade pip
pip install jupyter
jupyter notebook Contact: The goal of this introduction was to teach the minimum you need to get up and running. This is not a formal training, but a means to get you into doing Physics with pyROOT. I am always interested in keeping this page live with hints, tips and techniques that you find useful and may help out others. Please email suggestions to itsupport@physics.ox.ac.uk. | |||
Documents | |||
| File | Heading | Date | |
| Drupal page URL | 06-12-2024 10:33 | ||
| | KB image | 06-12-2024 10:33 | |
| | KB image | 06-12-2024 10:33 | |
| | KB image | 06-12-2024 10:33 | |
| Writer: Vipul Davda Created on 22-10-2012 10:10 Last update on 06-12-2024 14:37 | 340 views This item is part of the Physics IT knowledgebase | ||