Best tools for Maximo Development

In this article I would like to give you some useful tips on tools I normally when developing things for Maximo. If you have any more recommendation please feel free to share your experiences in the comment section.

Editing Text and Code

Eclipse Development Environment for Java, Javascript and Jython

I already had a couple of articles related to that topic:

Atom, the alternative, powerful Editor and development platform

Atom is a powerful and very extensible text editor to solve all kind of editing challenges. Atom is available for all common platforms like Windows, Mac and Linux. Due to it’s very slim design and easy usage it really can act as the primary working editor on your system, but it can be extended to a very good development platform. For myself i mostly like it for Web and Javascript development topics. For Java and Jython I still prefer Eclipse.

Atom can be downloaded via https://atom.io.

After downloading it you should have a look to it’s package extensions. My favored one are:

  • linter & linter-eslint (Javascript) & … : The linter package is a tool for code check using rules. Beside the base package there are a lot of additional packages for different programming languages.
  • platformio-ide-terminal: A terminal package for the Atom.
  • Remote-FTP: Enable browsing of FTP/FTPP/SFTP
  • open-recent: Open recent files in the current window
  • minimap: A preview of the full source code
  • highlight-selected: Highlights the current word selected when double clicking

Simple Text-Editors

If you just need a good Text editor you could use Notepad++ as a good alternative for Windows.

Database tools

If you are developing Maximo scripts you regularly need to have a powerful, free tool to work with databases. My favourit tool is SQuirreL SQL. SQuirreL is a Universal SQL client, which can via JDBC connect to most Database Servers like IBM DB2, Oracle, Informix, MS SQL or MySQL. All with the same Look and feel which makes handling much easier for the consultant.

Another handy, but not unfortunately not longer developed tool to create E-R Models in documentation is the Painting program DIA. DIA is an alternative to MS Visio, and can be used for a brought range of creating diagrams.

Interface tools

If you develop Maximo interfaces there are a couple of useful tools you need in your toolbox. If you need to simulate or test http based interfaces like WebServices or Rest you should have a look on Postman. Postman is available as a standalone version as well as a plugin for the Chrome browser.

An alternative for testing which is a bit more powerful, but also more complicated in usage is SoapUI. Beside simple API testing it features more protocols like JDBC.

Other tools

When using Maximo on Windows platform you are often missing a tail like command. Wintail or BareTail are alternatives which could be downloaded here.

Navigating in Asset Hierarchies

In todays article I would like to explain how easy it is to navigate in Asset Hierarchies with method provided in the businessobject.jar file. Basically you can code all the shown stuff by using the basics you already learned about Mao’s, MboSet’s and Relationships, but why not take some easy predefined functions to do so.

For our example I will take a simple Asset Hierarchy which comes from the Maximo demo data set.

The following script will recursively output this hierarchy using the RMI technique:

import psdi.util.MXSession as MXSession
from psdi.util import MXException

try:
    session = MXSession.getSession()
    session.setHost('mx7mssa:13400/MAXIMO')
    session.setUserName('maxadmin')
    session.setPassword('********')
    session.connect()

except MXException, conex:
    print 'conex.getErrorGroup()     :',conex.getErrorGroup()
    print 'conex.getErrorKey()       :',conex.getErrorKey()
    print 'conex.getDetail()         :',conex.getDetail()
    print 'conex.getDisplayMessage() :',conex.getDisplayMessage()
    exit

def getChildrenHierarchy(asset, level):
    if asset.hasChildren():
        tab = " " * (level * 3)
        print tab + "Asset " + asset.getString("ASSETNUM") + " has childrends:"

        # GetChildren uses Relationship "ASSETCHILDREN"
        childrenSet = asset.getChildren()
        childrenMbo = childrenSet.moveFirst()
        while(childrenMbo != None):
            print tab + "--------------------"
            print tab + "Children Asset: " + childrenMbo.getString("ASSETNUM")
            if childrenMbo.hasParents():

                ownerMbo = childrenMbo.getMyParent()
                print tab + "Owner Object (Parent) for Children is " + ownerMbo.getString("ASSETNUM")

                parentMboSet = childrenMbo.getParents()
                parentMbo = parentMboSet.moveFirst()
                while (parentMbo != None):
                    print tab + "Parent MBO is: " + parentMbo.getString("ASSETNUM")
                    parentMbo = parentMboSet.moveNext()

                rootParentMbo = childrenMbo.getRootParent()
                print tab + "Root-Parent for Children is " + rootParentMbo.getString("ASSETNUM")
            # now recursivly look for more childrens...
            if childrenMbo.hasChildren():
                getChildrenHierarchy(childrenMbo, level + 1)

            childrenMbo = childrenSet.moveNext() 

# Main Program
if session.isConnected():
    assetSet = session.getMboSet('ASSET')
    assetSet.setWhere("ASSETNUM = '11400'")
    asset = assetSet.moveFirst()

    assetNum = asset.getString("ASSETNUM")
    print "Asset ", assetNum, " is the top asset in the hierarchy."

    getChildrenHierarchy(asset, 1)

The following table is a reference of all methods we have used in the script:

MethodDescription
assetSet = asset.getChildren()Returns a Mbo Set with all children of an asset based on the "ASSETCHILDREN" relationship
asset.hasChildren()Checks if the asset has children asset objects (returns boolean)
asset.hasParents()Checks if the asset has parent asset objects (returns boolean)
assetSet = asset.getParents()Gets a Mbo Set of all Parent asset objects based on the "PARENT" relationship.
myMbo = asset.getMyParent()Returns the Mbo of the owner Object. In our sample, a simple hierarchy, it will provide the same Mbo like the getParents() method. But the owner Mbo is more generic and could be for example a Workorder, if you navigate from a Workorder Object to an asset related to a Workorder.
topAsset = asset.getRootParent()Gets the root Mbo of the asset hierarchy.

Testing Automation Scripts with the new Maximo 7.6 “Testscript” method (Part 1: MBO based scripts)

Maximo introduced with Version 7.6 a new feature which allows you to test your automation scripts in context of a new or an existing Mbo as well as in context of the Maximo Integration Framework (MIF). The downside of that new function is, that I currently have not found any good documentation and that some features like the object path syntax are not self explaining. In this two part series I would like to introduce the new “Testscript” feature and explain how easy it is to use for your daily script testing. In part 1 we will cover the test of scripts in context of a Mbo and in part two I will show you how to test in context of the MIF.

The old styled “Run Script” testing is no longer visible but can be enabled again using the trick in my other post.

The first thing to mention if you want to test a script with the new Mbo-Test functionality is, that you need to have a script with an Object Launchpoint. Scripts with attribute launchpoints are not tested, or even worse if you have both: an object launch point and a attribute launch point on the same Mbo always the object script runs, even you select the attribute launch point script! (Might be confusing!). On the the other hand side you could utilize an Object Launchpoint testscript to set a certain attribute in a Mbo which then triggers the attribute launchpoint as well 😉

Now lets create a very simple script with an object launch point for the ASSET object like the following:

print "Hello World"
print mbo.getString("ASSETNUM")

Press the “Test Script” button and you will see the following dialog:

At the top you will see information about the script and the selected Launchpoint we are running on.

With 1. you will select if we want the script to be tested against a newly created object or and existing object.

In 2. an object path can be specified if we want to reference an existing object. The format I currently found out is:

<OBJECT>[<SQL-WHERE>]

Examples could be:

ASSET[ASSETNUM='11200']
ASSET[ASSETNUM like '11%']
ASSET[ISRUNNING = 1]
ITEM[ITEMNUM='0815']

Important to remember, that you always get only a single resulting record to your script. This is the default behavior for an object script, where the resulting set is stored in the implicit Launchpoint variable mbo.

If you select Existing Object and specify an Object Path (remember to copy the Object Path to the clipboard – you have to reenter it for every test!) you can press the Test button.

You might see a result as follows:

  1. Data contains the resulting MBO in XML format.
  2. Log contains the output of the Print statements of the script.

With the Set attribute values section you can specify attributes which are overwritten from the original result. This is a nice feature when you need some testing data with certain specification (e.G. We need an asset in status of not running (ISRUNNING = 0), so we just need to specify:

So far we just have discussed the Existing Object path. If you like to create a New Object this also can be done with the testing function. The testing function basically calls an mboSet.addAtEnd() function to append a new record to the given MboSet. With the usage of Set attribute values you can predefine fields of the newly created Mbo before it is handed over to the Jython script.

A bit strange is, that if you try to create an Asset Object and do not specify an ASSETNUM you will get an error, that the asset field needs to be specified. If you will set the ASSETNUM field you will get an error, that it is readonly and cannot be set.

The only solution I found so far is to hardly overwrite the readonly check by using the Field Flag “NOACCESSCHECK”:

from psdi.mbo import MboConstants
mbo.setValue("ASSETNUM", "ASS0815", MboConstants.NOACCESSCHECK )
mbo.setValue("DESCRIPTION", "New Test Asset!")

So far for this first tutorial on the new Test script capability. In the next part I will cover the capability to test automation scripts customizing the MIF Interface.

Missing “Launch Script” function in Autoscript Application in Maximo / ICD 7.6

Have you ever noticed that starting with Maximo or ICD Version 7.6 the „Launch Script“ action is missing in the Automationscript Application? You might missing the following running person: Selection_042 If so you should read this post to solve this issue.

The new Maximo / ICD 7.6 still has all the functionality build in to run a script interactively. The only missing thing is a missing Signature Option “EXECUTE” in the automation Script application. To define this execute the following steps:

  1. Open Application Designer Application
    Goto > System Configuration > Plattform Configuration > Application Designer
  2. Search and open the “AUTOSCRIPT” application
  3. When you opened the “AUTOSCRIPT” application in Application Designer select from the “Select Action” Menü:
    “Add/Modify Signature Options”
  4. Click on “New Row” in the dialog
  5. Define a new Signature Option “EXECUTE” with a Description “Launch Script”
  6. Goto > Security > Security Groups and add the newly created signature option to the Automationscript application.

Now you only have to logoff and logon again to see the new action in the application script application. In Maximo I had to add the newly created signature option to a Security Group. Maybe you have to go this extra step too…

Hope this will help you to easily test Scripts!

Automation Scripting Basics

In this blog I would like to provide you an overview of the terms and capabilities used in context of Maximo automation scripting. With the Automation Scripts application, you can create scripts to automate tasks based on the events or attributes of a business object, or based on actions or custom conditions.

There are some basic components to understand in terms of automation scripting:

Automation Script: The automation script is a definition in the Maximo Database which defines the name, a description and some more attributes for a specific automation script. The Source code of the script is an important part of this definition.

The source code must be written in the languages that are supported by the following script engines:

  • Mozilla Rhino, version 1.6 release 2
  • Jython, Version 2.5.2 (Version 2.7 starting ICD/Maximo 7.6.1)

Launch point: A launch point defines the context and the trigger for an automation script. A launch point has a name and a description. An automation script can have multiple launch points defined as a trigger. On the opposite side an automation script can be triggered in different ways without a specific launch point. Read this blog for the possible ways to trigger a script.

Variables and binding Values: Specific variables are automatically (implicit) passed to and from the Maximo scripting framework to your automation script. In addition you can defined more user defined variables to be passed. Read this blog for details on implicit variables.

Jython Script Templates

In this post I would like to provide you simple script templates which can be used for your next project and which are compatible to the Eclipse to Maximo/ICD Upload Script described in this blog.

The following Template is for general usage in a Launchpoint script:

#-------------------------------------------------------------
# AUTOSCRIPT:         SIMPLETEMPLATE
# DESCRIPTION:        Sample template for usage in Launchpoints   
# AUTHOR:             Matthias Stroske
# Created             01.10.15
# LOGLEVEL:           ERROR
# VERSION:            3.0
# COPYRIGHT:          (c) Matthias Stroske
# MAXIMO/ICD Version: 7.6
#-------------------------------------------------------------
# History of Changes
#
# Ver  Date      Name               Description
# 1.0  01.10.15  Matthias Stroske   Initial Version
#
#-------------------------------------------------------------

mbo  # @UndefinedVariable

If you are using a CVS System to check in your scripts you can use the following template. A couple of sections like $Author: $, $Revision: $ and so on are automatically filled in when you check in your code. Specially the $Revision: $ tag is very nasty in combination with the Maximo Script upload, because it automatically sets the Verion of the Script in Maximo to the CVS Revision. Here the full template:

#-------------------------------------------------------------
# AUTOSCRIPT:         SIMPLETEMPLATE
# DESCRIPTION:        Sample template for usage in Launchpoints with CVS Integration
# LOGLEVEL:           ERROR
# COPYRIGHT:          (c) Matthias Stroske
# MAXIMO/ICD Version: 7.6
#-------------------------------------------------------------
# $Author: $
# $Revision: $
# $Date: $
#-------------------------------------------------------------
# History of Changes
#
# $Log: $
#
#-------------------------------------------------------------

mbo  # @UndefinedVariable

MboValueAdapter – Initial, Previous and Current Value of a Field

The Theory

Sometimes when you work with a Mbo in a MboSet and you change fields in a Mbo it can be useful to get the original value of the field before the change or the last value of the field before the change. So basically a field can have three different values we can ask for:

valudata2

For that purpose we can use the MboValueAdapter and MboValue classes which are automatically initialized with every Mbo retrieved from the system

The CurrentValue of  a field usually can be red by the following Method via a Mbo:

wonum = mbo.getString("WONUM") # wonum is of type string

The same action can also be achived using the MboValueAdapter:

mboValue = mbo.getMboValue("WONUM") # mboValue is of Type mboValue 
currentValue = mboValue.getCurrentValue() # currentValue is of type MaxType
wonum = currentValue.asString() # wonum is of type string

# The previous 3 lines can be merged to a shorter Version:
wonum = mbo.getMboValue("WONUM").getCurrentValue().asString()

Now the short version to get the PreviousValue which can only be get via the MboValueAdapter:

wonum = mbo.getMboValue("WONUM").getPreviousValue().asString()

And the InitialValue which represents the state of the field when the MboSet was initialized:

# Version via Mbo:
wonum = mbo.getDatabaseValue("WONUM")

# Version via ValueAdapter
wonum = mbo.getMboValue("WONUM").getInitialValue().asString()

 

A practical Example

The scenario I will show you in this blog is based on the Item Mbo and the description field. I will change this field several times and always show you the initial, previous and current value of this field. Precisely we will do the following:

valuedata

As you can see we have 3 point where we verify our output. The following script can be easily created as a new Automation Script without launchpoint. Just take it and try it out! You can run the Script directly from the Automation Script Application.

#AUTOSCRIPT:MBOVALUEADAPTER
#DESCRIPTION:Demo to show usage of mboValueAdapter
#LOGLEVEL:ERROR
from psdi.server import MXServer
from psdi.iface.mic import MicService

# Initialize some stuff
mxServer = MXServer.getMXServer()
micService = MicService(mxServer)
micService.init()
userInfo = micService.getNewUserInfo()

itemSet = mxServer.getMboSet('ITEM', userInfo)
itemMbo = itemSet.moveFirst()
 
# Get Value for Field Description
stringValue = itemMbo.getString("DESCRIPTION")
itemMbo.setValue("DESCRIPTION", stringValue + "_new")

# Get the new Value again
stringValueNew = itemMbo.getString("DESCRIPTION")
itemMbo.setValue("DESCRIPTION", stringValue + "_new_new")

# Get the new Value again
stringValueNewNew = itemMbo.getString("DESCRIPTION")

print "Original Value              = " + stringValue
print "Value after 1. Change       = " + stringValueNew
print "Value after 2. Change       = " + stringValueNewNew


# Now work with the new MboValue Class
mboValue = itemMbo.getMboValue("DESCRIPTION")
initialValueAsString = mboValue.getInitialValue().asString()
previousValueAsString = mboValue.getPreviousValue().asString()
currentValueAsString = mboValue.getCurrentValue().asString()

print "-----------------------------------------------------------------"
print "Now Mbo Value Results"
print "-----------------------------------------------------------------"
print "Initial Value               = " + initialValueAsString
print "Previous Value before 2. change = " + previousValueAsString
print "Current Value from MboValue = " + currentValueAsString

itemSet.save()

print "-----------------------------------------------------------------"
print "Now After itemSet.save()"
print "-----------------------------------------------------------------"

mboValue = itemMbo.getMboValue("DESCRIPTION")
initialValueAsString = mboValue.getInitialValue().asString()
previousValueAsString = mboValue.getPreviousValue().asString()
currentValueAsString = mboValue.getCurrentValue().asString()

print "Initial Value               = " + initialValueAsString
print "Previous Value before 2. change = " + previousValueAsString
print "Current Value from MboValue = " + currentValueAsString

itemSet.reset()
itemSet = mxServer.getMboSet('ITEM', userInfo)
itemMbo = itemSet.moveFirst()

print "-----------------------------------------------------------------"
print "Now After itemSet.reset()"
print "-----------------------------------------------------------------"

mboValue = itemMbo.getMboValue("DESCRIPTION")
initialValueAsString = mboValue.getInitialValue().asString()
previousValueAsString = mboValue.getPreviousValue().asString()
currentValueAsString = mboValue.getCurrentValue().asString()

print "Initial Value               = " + initialValueAsString
print "Previous Value before 2. change = " + previousValueAsString
print "Current Value from MboValue = " + currentValueAsString

The output should look similar to the following:

Original Value              = IT Services
Value after 1. Change       = IT Services_new
Value after 2. Change       = IT Services_new_new
-----------------------------------------------------------------
Now Mbo Value Results
-----------------------------------------------------------------
Initial Value               = IT Services
Previous Value before 2. change = IT Services_new
Current Value from MboValue = IT Services_new_new
-----------------------------------------------------------------
Now After itemSet.save()
-----------------------------------------------------------------
Initial Value               = IT Services
Previous Value before 2. change = IT Services_new
Current Value from MboValue = IT Services_new_new
-----------------------------------------------------------------
Now After itemSet.reset()
-----------------------------------------------------------------
Initial Value               = IT Services_new_new
Previous Value before 2. change = IT Services_new_new
Current Value from MboValue = IT Services_new_new

Agile testing of Launchpoint scripts using a Testsection

Did you ever had the problem that it is sometimes really time consuming when you have to test a more complex script which is called by a script launchpoint? You mostly have to click through the ICD/Maximo GUI to simulate some behaviour so your script gets launched in context of a Mbo.

In this post I will show you a way how you can improve your script with a Testsection and after that you can run it from the Automation Script Application.

What is the base issue we have? Why is it so difficult to just run a script directly from the application editor? In most cases the answer is, that our script requires the context of a Mbo Object which is provided by the implicit variable mbo. So one of our first lines will be normally look like this:

itemMbo = mbo @UndefinedVariable

From this point on we can take the itemMbo to get/set values, to navigate to different MboSet’s and so on.

The easy solution to our issue is, that we need to simulate the mbo variable. If we run the script from the Automation Script Application no implicit variables are defined at all and so no mbo variable is available. We can test on this situation and then initialize the mbo variable our self by getting a specifc Mbo via the MxServer context. The following script will show this in context of the Item Mbo:

from psdi.server import MXServer

mxServer = MXServer.getMXServer()
userInfo = mxServer.getSystemUserInfo()

# Section to test script from Scripteditor
try:
    mbo  # @UndefinedVariable
except NameError:
    mbo = None

if mbo is None:
    scriptHomeSet = mxServer.getMboSet("ITEM", userInfo)
    scriptHomeSet.setWhere("ITEMNUM='ITAMT61'")
    scriptHomeSet.reset()
    itemMbo = scriptHomeSet.getMbo(0)
else:
    # This is the normal Entrypoint via a Launchpoint since 
    # implicit variable mbo is defined.
    itemMbo = mbo  # @UndefinedVariable

itemNum = itemMbo.getString("ITEMNUM")
itemDesc = itemMbo.getString("DESCRIPTION")

You just have to change the selection criteria in line 14 to select the correct item record. After that run the script from the Automation Script Application (see here for details).

You can integrate this technique in basically any script initiated by a launchpoint. Only remember, that no implicit variables can be used in the script!

The UserInfo Object

In this Blog I would like to introduce the often used UserInfo Object in Maximo / ICD Scripting. There are a number of API method calls which require a UserInfo object as a parameter and therefore you need to know how you can get a reference to that object. In the second part of this Blog I will show you some method calls on utilizing the UserInfo yourself.

Get a reference to the UserInfo Object

When you run a Jython script there are several ways to get the UserInfo object.

Get the userInfo for the actual user the script is running in context of a Mbo

userInfo = mbo.getUserInfo()  # @UndefinedVariable

Get the userInfo for a specific user

from psdi.server import MXServer
mxServer = MXServer.getMXServer()
userInfo = mxServer.getUserInfo("maxadmin")

Get the userInfo for the System user (MAXADMIN)

from psdi.server import MXServer
mxServer = MXServer.getMXServer()
userInfo = mxServer.getSystemUserInfo()

Get a new userInfo from Migration Services (MAXINTADM)

from psdi.server import MXServer
from psdi.iface.mic import MicService

mxServer = MXServer.getMXServer()
micService = MicService(mxServer)
micService.init()
userInfo = micService.getNewUserInfo()

Usage of the UserInfo Object

Beside the fact that a lot of method calls of Maximo/ICD API’s require a valid UserInfo object it has a number of interesting Getter and Setter functions which might help in your script. I would like to show you only a small subset of these methods here in this Blog. A full list can be found in the JavaDoc documentation.

Here I have a small demonstration script for you which can be directly run from the Automation Scripts application (look here how to do this):


#AUTOSCRIPT:USERINFO
#DESCRIPTION:Example script showing capabilities of the UserInfo Object

from psdi.server import MXServer

mxServer = MXServer.getMXServer()
userInfo = mxServer.getSystemUserInfo()

print "User Name        : " + str(userInfo.getUserName())
print "User Display Name: " + str(userInfo.getDisplayName())
print "User ID          : " + str(userInfo.getLoginUserName())
print "e-Mail           : " + str(userInfo.getEmail())
print "Default Language : " + str(userInfo.getDefaultLang())
print "Current Language : " + str(userInfo.getLangCode())
print "Timezone         : " + str(userInfo.getTimeZone())

# Check if we are running in Interactive mode or from a System Job
if not userInfo.isInteractive():
    print "------"
    print "Setting Language code to EN"
    print "------"

    userInfo.setLangCode("EN")

    print "Current Language : " + str(userInfo.getLangCode())

The script basically will get the UserInfo Object for the System User and after that will print out a couple of information. To avoid Null Pointers I have convert all outputs to a String value. At the end it will change the current Language for the user to a different value.


User Name        : MAXADMIN
User Display Name: Max Admin
User ID          : MAXADMIN
e-Mail           : None
Default Language : DE
Current Language : DE
Timezone         : sun.util.calendar.ZoneInfo[id="America/New_York",offset=-18000000,dstSavings=3600000,useDaylight=true,transitions=235,lastRule=java.util.SimpleTimeZone[id=America/New_York,offset=-18000000,dstSavings=3600000,useDaylight=true,startYear=0,startMode=3,startMonth=2,startDay=8,startDayOfWeek=1,startTime=7200000,startTimeMode=0,endMode=3,endMonth=10,endDay=1,endDayOfWeek=1,endTime=7200000,endTimeMode=0]]
------
Setting Language code to EN
------
Current Language : EN

Some obstacles in the output:

  1. The Timezone value is not a string, but another object of type sun.util.calendar.ZoneInfo. If you need that information you should access the value with the methods from the ZoneInfo object.
  2. I implemented a condition “userinfo.isInteractive()”. Interactive normally means, that a script runs based on an end user GUI Action, while “not interactive” means that the script runs from an escalation or automatic workflow. If we run the script manually from the Automation Script application the isInteractive() method returns false.

Using Maximo messages in multi-language environments

Your first question could be in this context: “Why should I read translations from the message system? Messages for Message boxes are always displayed in the correct user language!”. This is correct, but let’s assume the case that you would like to fill in a String field in a MBO Record with a text string which should be in the correct language of the user who is using the system. In that case we are not using the message system of Maximo in it’s original form, but more as a translation vehicle which we can ask: “I have a user with German language settings here. Please provide me the the defined translation for message <msggroup> <msgkey>.”. This is cool.

I will show you with a short script how you can display the content of messages in different languages:

#DESCRIPTION:Translate messages in the User-Language
from psdi.server import MXServer

mxServer = MXServer.getMXServer()
userInfo = mxServer.getUserInfo("maxadmin")

def getMessage(group, key):
    return MXServer.getMXServer().getMessage(group, key, userInfo.getLangCode())

userInfo.setLangCode("EN")  
print getMessage("access", "DBConnectFail")
print "--------------------------------------------------------"

userInfo.setLangCode("DE")
print getMessage("access", "DBConnectFail")

The result from running this script directly in the Automation Script Application (see here for details how to run a script) should look as follows:

Selection_045

Quit cool. Instead of printing out the message you could assign it to a variable and do some work on it…

1 2 3 4