1Modeling Engineering Objects with Classes
▶
Object-oriented programming becomes especially powerful when its abstractions map directly onto the physical world. In engineering, you routinely deal with concrete objects — beams, sensors, pumps, circuits, materials — each of which has measurable properties and well-defined behaviors. Python classes give you a natural way to encode that structure: attributes capture the state of a physical object, and methods encode the formulas, checks, and actions that operate on that state. The result is code that mirrors engineering thinking so closely that reading the class definition feels like reading a specification sheet or a design document.
This topic walks through the full process of designing, implementing, and combining engineering classes, using a structural beam and an environmental sensor as running examples. Along the way you will see how good class design eliminates repetitive formula code, how objects can reference other objects to model system-level relationships, and how Python's special string-representation methods make debugging sessions faster and less frustrating.
Designing an Engineering Class
Before writing a single line of Python, the most valuable step is to think carefully about what the class is supposed to represent. Good class design in an engineering context starts with two questions: what does this object know? and what can this object do? The answers translate directly into attributes and methods respectively.
Start by listing the physical properties the object must store. For a rectangular cross-section beam you might list: length, width, height, and the material's Young's modulus. These become instance attributes because every individual beam can have different values. Avoid the temptation to add properties that belong to a different concept — the load applied to the beam, for example, is probably an input to a calculation rather than a permanent property of the beam itself.
Next, identify the calculations or actions the object should be able to perform. For a beam: compute cross-sectional area, compute second moment of area, compute maximum bending stress given an applied moment, compute deflection under a point load. These become methods. Notice that each formula naturally uses the beam's own stored dimensions — that is precisely what makes methods superior to standalone functions scattered across a project.
Keep each class focused on a single engineering concept. A class called BeamAndLoadAndMaterial that tries to do everything will be hard to test, hard to read, and hard to reuse. Splitting responsibilities into focused classes — even if those classes later reference each other — produces a much more maintainable codebase.
Document the class and its attributes using docstrings. Python's convention is to place a triple-quoted string immediately after the class line and immediately after the def line of each method. A good class docstring names the concept being modeled and states the expected units and types of each attribute. This documentation is not optional in engineering work; it is the difference between a class that can be safely handed to a colleague and one that requires a conversation before it can be used.
class RectangularBeam:
"""
Represents a rectangular cross-section structural beam.
Attributes
----------
length : float
Span of the beam in metres.
width : float
Width of the cross-section in metres.
height : float
Height (depth) of the cross-section in metres.
elastic_modulus : float
Young's modulus of the beam material in Pascals (Pa).
"""
def __init__(self, length, width, height, elastic_modulus):
self.length = length
self.width = width
self.height = height
self.elastic_modulus = elastic_modulus
Even this skeleton communicates a great deal: the units are stated, the attribute names are descriptive, and the relationship between the constructor parameters and the stored state is obvious.
Implementing a Beam Class
With the design sketched out, the implementation follows naturally. Geometric properties such as length, width, and height are stored as instance attributes in __init__, exactly as shown above. Engineering formulas then become methods that operate on the beam's own stored data via self.
Consider the second moment of area (also called the area moment of inertia) for a rectangular section, which governs how resistant the beam is to bending. The formula is I = (b × h³) / 12 where b is width and h is height. Encoding this as a method means the formula is defined once, tested once, and available on every beam instance without any risk of copying the wrong variable name into a standalone function:
def cross_sectional_area(self):
"""Return the cross-sectional area in square metres."""
return self.width * self.height
def second_moment_of_area(self):
"""Return the second moment of area about the neutral axis (m^4)."""
return (self.width * self.height ** 3) / 12
def max_bending_stress(self, moment):
"""
Return the maximum bending stress in Pascals.
Parameters
----------
moment : float
Applied bending moment in Newton-metres (N·m).
"""
I = self.second_moment_of_area()
y = self.height / 2 # distance from neutral axis to outer fibre
return (moment * y) / I
def midspan_deflection_point_load(self, load):
"""
Return the midspan deflection (m) for a simply supported beam
with a central point load.
Parameters
----------
load : float
Applied point load in Newtons (N).
"""
E = self.elastic_modulus
I = self.second_moment_of_area()
L = self.length
return (load * L ** 3) / (48 * E * I)
Notice how max_bending_stress calls self.second_moment_of_area() rather than repeating the formula inline. This is an important habit: methods can call other methods on the same object. If you later discover that the second moment of area formula needs a correction factor (for composite sections, say), you change it in exactly one place and every other method that depends on it immediately benefits.
Returning computed values from methods rather than printing them inside the method is equally important. A method that returns its result can be used in further calculations, stored in a variable, passed to another function, or accumulated into a list. A method that only prints is a dead end.
# Create a steel I-like beam (approximated as rectangular for illustration)
# E for steel ≈ 200 GPa = 200e9 Pa
beam = RectangularBeam(length=5.0, width=0.1, height=0.3, elastic_modulus=200e9)
I = beam.second_moment_of_area()
print(f"Second moment of area: {I:.6e} m^4") # 2.250000e-04 m^4
stress = beam.max_bending_stress(moment=15000) # 15 kN·m
print(f"Max bending stress: {stress/1e6:.2f} MPa") # 100.00 MPa
deflection = beam.midspan_deflection_point_load(load=10000) # 10 kN
print(f"Midspan deflection: {deflection*1000:.3f} mm")
The class has transformed a set of formulas that would otherwise be copy-pasted across a project into a coherent, reusable object. Each new beam — different size, different material — is simply a new instance with different constructor arguments.
Implementing a Sensor Class
Sensors are another engineering object that maps naturally onto a class. A sensor has identity (a name or serial number), configuration data (the physical range it can measure, its units, possibly a calibration offset), and a primary action (returning a reading). It may also need to validate whether a reading is within its specified operating range — readings outside that range might indicate a faulty sensor or a dangerous process condition.
import random
class TemperatureSensor:
"""
Represents a temperature sensor with a defined measurement range.
Attributes
----------
sensor_id : str
Unique identifier for the sensor.
location : str
Physical location description (e.g. 'Reactor inlet').
min_range : float
Minimum measurable temperature in degrees Celsius.
max_range : float
Maximum measurable temperature in degrees Celsius.
calibration_offset : float
Offset added to raw readings to correct for sensor bias (°C).
"""
def __init__(self, sensor_id, location, min_range, max_range,
calibration_offset=0.0):
self.sensor_id = sensor_id
self.location = location
self.min_range = min_range
self.max_range = max_range
self.calibration_offset = calibration_offset
def read_value(self):
"""
Simulate reading the sensor and return a calibrated temperature (°C).
In a real system this method would query hardware or a data bus.
Here it returns a random value within the sensor range for illustration.
"""
raw = random.uniform(self.min_range, self.max_range)
return raw + self.calibration_offset
def is_in_range(self, value):
"""
Return True if value falls within the sensor's specified range.
Parameters
----------
value : float
Temperature reading in degrees Celsius.
"""
return self.min_range <= value <= self.max_range
def validated_reading(self):
"""
Return a tuple (reading, valid) where valid is a boolean.
Allows calling code to decide how to handle out-of-range readings.
"""
reading = self.read_value()
return reading, self.is_in_range(reading)
The read_value method uses random.uniform to simulate hardware — in a real deployment you would replace those two lines with whatever library call reads from the physical device, leaving the rest of the class completely unchanged. This is the practical value of encapsulation: the calling code never needs to know how the reading is obtained, only that it can call read_value() and get a number back.
Managing multiple sensors as a list of instances is straightforward and extremely readable:
sensors = [
TemperatureSensor("T-001", "Reactor inlet", -10, 150, calibration_offset=0.5),
TemperatureSensor("T-002", "Reactor outlet", -10, 200, calibration_offset=-0.2),
TemperatureSensor("T-003", "Cooling jacket", 0, 80, calibration_offset=0.0),
]
for sensor in sensors:
reading, valid = sensor.validated_reading()
status = "OK" if valid else "OUT OF RANGE"
print(f"[{sensor.sensor_id}] {sensor.location}: {reading:.2f} °C — {status}")
A single loop iterates over every sensor in the system, calls the same method on each, and processes the result identically regardless of the specific sensor's range or calibration. Adding a fourth sensor requires one extra list entry — no changes to the loop logic whatsoever.
Using Objects Together in Engineering Systems
Real engineering systems are composed of interacting components. A pump assembly contains a motor, an impeller, and a pressure sensor. A structural frame contains multiple beams and connections. Python classes model these relationships through composition: one object holds a reference to another object as an attribute.
Suppose you want to model a simply supported beam with a sensor attached to it that monitors deflection. You can create a MonitoredBeam class that contains both a RectangularBeam and a TemperatureSensor (here repurposed conceptually as a generic sensor), and whose methods coordinate between them:
class DeflectionSensor:
"""Simulates a deflection sensor with a maximum deflection threshold."""
def __init__(self, sensor_id, max_deflection_mm):
self.sensor_id = sensor_id
self.max_deflection_mm = max_deflection_mm
def check(self, deflection_m):
"""Return True if deflection is within the acceptable limit."""
deflection_mm = deflection_m * 1000
return deflection_mm <= self.max_deflection_mm
class MonitoredBeam:
"""
A structural beam paired with a deflection monitoring sensor.
Attributes
----------
beam : RectangularBeam
The structural beam being monitored.
sensor : DeflectionSensor
The sensor attached to the beam.
"""
def __init__(self, beam, sensor):
self.beam = beam # composition: beam is an attribute
self.sensor = sensor # composition: sensor is an attribute
def apply_load(self, load):
"""
Compute deflection under a central point load and check sensor status.
Parameters
----------
load : float
Applied central load in Newtons.
Returns
-------
dict with keys 'deflection_mm' and 'sensor_ok'.
"""
deflection = self.beam.midspan_deflection_point_load(load)
sensor_ok = self.sensor.check(deflection)
return {
"deflection_mm": deflection * 1000,
"sensor_ok": sensor_ok
}
Now the calling code reads almost like an engineering workflow:
steel_beam = RectangularBeam(length=6.0, width=0.15, height=0.4, elastic_modulus=200e9)
deflection_monitor = DeflectionSensor(sensor_id="D-001", max_deflection_mm=15.0)
monitored = MonitoredBeam(beam=steel_beam, sensor=deflection_monitor)
result = monitored.apply_load(load=25000) # 25 kN
print(f"Deflection: {result['deflection_mm']:.3f} mm")
print(f"Within limit: {result['sensor_ok']}")
The apply_load method on MonitoredBeam calls self.beam.midspan_deflection_point_load(load) — a method on the associated beam object — and then calls self.sensor.check(deflection) on the associated sensor. Neither RectangularBeam nor DeflectionSensor needed to be modified; they remain focused on their own responsibilities. MonitoredBeam coordinates them. This mirrors systems engineering practice: components expose defined interfaces, and a system-level entity wires them together.
Composition scales naturally. A bridge model might contain a list of MonitoredBeam objects, a list of connection nodes, and a load-case manager. Each layer remains manageable because each class has a single, clear responsibility.
String Representation with __str__ and __repr__
Python provides two special methods for controlling how an object appears as text. Understanding the distinction between them — and implementing both — is a small investment that pays off throughout a project.
__str__ is called when you pass an object to print() or to str(). It is intended to produce a human-readable description of the object — something you would be comfortable showing to a colleague or writing to a log file. It should be concise and informative.
__repr__ is called in the interactive interpreter when you type an object's name and press Enter, or when an object appears inside a container that is being printed. It is also the fallback if __str__ is not defined. Its purpose is to produce an unambiguous representation — ideally one that could be pasted back into Python to recreate the object, though in engineering classes with complex state this is sometimes relaxed to mean "a string that clearly identifies the object's type and key state."
class RectangularBeam:
"""Rectangular cross-section structural beam."""
def __init__(self, length, width, height, elastic_modulus):
self.length = length
self.width = width
self.height = height
self.elastic_modulus = elastic_modulus
# ... (methods as defined earlier) ...
def __str__(self):
return (f"RectangularBeam | L={self.length} m, "
f"b={self.width} m, h={self.height} m, "
f"E={self.elastic_modulus/1e9:.0f} GPa")
def __repr__(self):
return (f"RectangularBeam(length={self.length!r}, "
f"width={self.width!r}, height={self.height!r}, "
f"elastic_modulus={self.elastic_modulus!r})")
beam = RectangularBeam(5.0, 0.1, 0.3, 200e9)
print(beam)
# RectangularBeam | L=5.0 m, b=0.1 m, h=0.3 m, E=200 GPa
print(repr(beam))
# RectangularBeam(length=5.0, width=0.1, height=0.3, elastic_modulus=200000000000.0)
beams = [
RectangularBeam(3.0, 0.08, 0.2, 200e9),
RectangularBeam(6.0, 0.15, 0.4, 70e9), # aluminium
]
print(beams)
# [RectangularBeam(length=3.0, ...), RectangularBeam(length=6.0, ...)]
Without these methods, print(beam) would output something like <__main__.RectangularBeam object at 0x7f3a2c10d6a0> — the memory address of the object, which tells you nothing useful. With them, a quick print call immediately confirms which beam you are looking at and what its key properties are. When a calculation produces an unexpected result, being able to inspect objects directly in the console without writing extra diagnostic code is a significant time-saver.
For the sensor class, a similarly informative pair of methods might look like this:
def __str__(self):
return (f"TemperatureSensor [{self.sensor_id}] at '{self.location}' "
f"| Range: {self.min_range}–{self.max_range} °C "
f"| Offset: {self.calibration_offset:+.2f} °C")
def __repr__(self):
return (f"TemperatureSensor(sensor_id={self.sensor_id!r}, "
f"location={self.location!r}, "
f"min_range={self.min_range!r}, max_range={self.max_range!r}, "
f"calibration_offset={self.calibration_offset!r})")
Including key attribute values — the sensor ID, location, and range — means that log output produced by writing print(sensor) inside a monitoring loop is immediately actionable: you can tell at a glance which sensor logged which reading without cross-referencing a lookup table.
Taken together, these practices — thoughtful attribute selection, formula encapsulation in methods, composition for system-level modeling, and expressive string representations — form a coherent approach to engineering software design. The code you write describes not just a computation but the physical system behind it, making it easier to verify, extend, and hand over to others.