User Tools

Site Tools




Tinavile is a fourth 3kg sumo robot built in TUT Robotics Club. The name of “Tinavile” means “leaded whistle” in direct translation, but it has an indirect meaning in Estonian language - the word is used in slang to describe fast flying object, object that disappear mysteriously or extremely drunk person :-). Tinavile is a successor of Lõvikonserv, where the idea of using brushless DC (BLDC) motors came from. Yet, the motors are the only components that have stayed the same as in Lõvikonserv, everything else is designed from scratch. Tinavile was built to compete with top Baltic Robot Sumo (BRS) robots. Designing started at the end of 2010, first prototype was ready on spring 2011.


3D model of Tinavile

By 2010 multi-wheeled robots dominated on BRS. Most robots had 4, but some even had 6 or 8 wheels (like Master-of-Disaster). Nobody used two-wheel robots because in that configuration, the front blade would have scratched the ring surface. Scratching is a matter today also, but the paints and coatings are stronger. So, as was common practice, Tinavile was also designed to use 4 wheels. We looked for the best motors that would fit side-by-side into 20 x 20 cm space. The best motors are obviously Maxon motors and the ones we found to fit and have plenty of speed and torque were 50W EC-i 40 BLDC motors. Lõvikonserv had planetary gearboxes with 3,7:1 ratio, but as in that configuration it had lot of speed but lack of power, Tinavile got 14:1 ratio gearboxes.

Basically, as in most cases, Tinavile was built around motors. Choosing the BLDC motors meant that BLDC motor drivers were needed. As Maxon and other companies BLDC drivers were too large and heavy a custom ones were designed and built. Having a BLDC motor means that it's possible to have a feedback from Hall sensors which can be used as encoder information. So the plan was not to waste that information and have a bi-direction communication bus between motor controllers and main controller. CAN bus was chosen to be used because it is reliable and widespread. Although it is not as simple as UART, SPI or I2C it has much advantages. Having a single communication interface meant that robot can be built from modules which only need the power and CAN connector.

Concept of modules was important factor in body design too. From previous experience we knew that changing motors of badly designed robot can mean disassembling of the whole body. Tinavile has a body framework that allows to mount the motors, wheels and electronics individually. That makes possible to have faster repair times. To achieve the well-fitting components we designed the mechanics and electronics at the same time, so if there was need for adjustments then the PCB and mechanic drawings were changed together. One of the key points in electronics was to have as few wires as possible, because wires can snap.

To make robot smart, we decided to use as many sensors as possible. Therefore, there are 10 digital infrared distance sensors and 8 line sensors. Four or two ultrasonic distance sensors were also in plan but were not used. For odometry, in addition to motor Hall sensors, 4 accelerometers are inside the robot to calculate movement direction and bumps. Since the Slayer robot, we have used a special kind of input-output conditions matching table for algorithm. For Tinavile we created a separate algorithm editing program. More about it in the software chapter.



As was said earlier, Tinavile has 50W EC-i 40 BLDC motors (article number 313320) with stock planetary gearboxes (article number 166933). These motors seemed to have good speed and torque by datasheet, but in real use we couldn't get those figures out of them. Of course our measurements were far from precise, because we calculated the speed by looking at the robot from video and stall torque by measuring the push force of fixed length arm to the scale. Actually, one of the motors burned during that measurement. And there are obviously the friction factors in gearbox and on wheels which we didn't count in. The lesson that was learned is to not expect the torque equal to the stall torque because that means lots of amperes which burn the motor quite fast. Divide the stall torque by 3 or 4 to get something that could be achieved for second or two. Don't take this as a rule of course ;-). To save the motors in case of software error or stall we use 10 A slow-burn SMD fuses and 11.5 A current limiter. The slow-burn fuse burns 4 s with 20A (at room temperature, according to the datasheet).


Welded framework Soldering aluminium parts

Reducing weight of the robot was important task that lead to the design of rigid body framework. We had used 3 mm aluminium alloy sheets on many other robots and took this material also as a basis for body. Instead of using bolts, spacers and other kind of fasteners, we decided to weld the framework from AL 5754 sheets by using AlMg5 welding wire. Two frameworks were welded, but only one was usable. The problem was in material deformation at the point of welding. As all 4 wheels and blade had to be in balance, deformation was not an option. As welded framework is not detachable, everything had to fit into it. To see if parts really fit together and the computer design is correct, we created a simple plastic body first.

Welded body was in use in 2011 spring, but in autumn we decided to use special aluminium soldering wire Durafix Easyweld to mount the framework, especially when framework needed some improvement anyway. Soldering was done by heating the aluminium to 400°C with gas torch, brushing the aluminium-oxide off and melting the Durafix wire between two parts. Soldered body framework didn't have deformations and it proved to survive the collisions and drops of harsh fights. Yet, with aluminium soldering, it is very important to have as clean and oxide-free aluminium surface as possible, otherwise the solder will drop off easily when knocking.


Wheel Metal and wood surface tires

One of the most complicated and hard-to-get components of the robot were the tires. The aluminium wheels were easy to design and order from CNC milling company but with tires it took many time and trials to find the good solution. We tried model car tires, bicycle inner tires, lot of single-component silicons and polyurethane, electronic potting resin and stuff like that. Finally we ended up with some solution, but it could be better.

Silicone mould

One of the easiest and best options for wooden sumo rings (some competions still have them) is Zhermack Elite Double 22 double-component silicone. This silicone needs mixing with 1:1 ratio and settles with ~15 minutes. We used two-part (inner and outer ring) mould to cast the tires. The result had good quality but it was quite difficult to find the right glue to fix the tire on the wheel. Loctite Super attack glues well until to the certain point where the bound snaps from wheel, and silicone with dried glue layer starts spinning quite easily on the wheel. So it happened in some competitions - when pushing the opponent, wheels were just spinning inside the tire until the tire ran off. Some kind of Russian rubber glue that is used to repair inflatable boats, had better grip. Although it can still snap off when pulling the silicone tire off the wheel, it retains it's soft rubbery state so it doesn't let the tire spin on wheel. As was said before, the Zhermack silicone wheels perform well on wooden rings, because they are sticky and won't deform like on robot with magnets on metal ring. Maybe Elite Double 32 is harder and fits on metal ring too, but we didn't get it.

For metal ring, we got rubber wheels cut with water jet from 30 mm sheet, but these tires were inaccurate in thickness (probably because of the deviation of jet stream inside such thick material). The surface was also too rough for good grip. Finally, we found a company called Lindiekspert, which coated our wheels with hardness 70 rubber belt. That rubber doesn't deform with magnets and has quite good grip, but only when tires are clean (but this applies to all tires). Don't know how, but the rubber is really well bounded with aluminium and there's no belt jointing point visible. So far these tires have performed very well, although after usage the surface has polished and is nearly shining, so the grip is not as good as in beginning anymore.

The wheels are attached to the motor gearbox shaft with smart Trantorque bushings, that squeese between shaft and wheel hole. These things cost some money but they are well worth it because they are easy to use, durable and there's no slack. Just like in F1 pitstop, it takes couple of seconds to exchange the wheel with Trantorque. Yes, it really is like it would sound in advertising ;)


Variety of blades used (2 on left are for moveable blade)

Even bigger headache than wheels, was the front plow blade. First prototype of Tinavile had movable blade with springs that forced it down, yet that configuration had a negative effect of raising rear wheel up when driving under the opponent. As very few robots used movable blade we went for the fixed blade on second body version, but made the blades quite easily replacable. Yet, finding the good blades was very hard. One thing was certain - the blade had to be made of steel, beacuse aluminium is soft as butter. Yet, there are many differents steels with different thicknesses and reinforcement levels. The best blade we got somehow from somebodys friend of friend was CNC milled from 1.5 mm reinforced steel. That was very sharp but once it got damaged it lost it's properties. And the bad thing about it was that it didn't bend so if body or blade bended then front wheels raised up.

We also tried random steel sheets but no matter if they were sharpened with milling or with grinding wheel they were too soft. In BRS'12 we cut blades out of palette knifes which were made of reinforced steel. They were flexible and slide on the ring very well, but got useless after being damaged or bended. So, we don't yet have a good receipe for blades…


Tinavile has 2 block magnets (1“ x 1” x 1/4“) on both sides and 6 ring magnets under the robot. The magnets give about 2-3 times of weight to robot. It would be possible to put more and stronger magnets, but there's a principal limit - the four wheeled robot needs to slide it's wheels in order to turn at one place. If there's too much pulling force, it doesn't move so rapidly anymore because grip doesn't let wheels slide. On the other hand, there should be more pulling force to have better grip when pushing the opponent and to push the blade on the ring as tightly as possible. We tried the 15 mm diameter electromagnet too which had core and coil with random diameter, but the force it provided was too little so it didn't pay off the weight increase. Of course there weren't any calculations behind it but as it didn't look promising we gave up.


Electronic modules Range of IR distance sensors

Tinavile has 7 electronic modules - main controller, 4 motor controllers and 2 side controllers, which take care of sensors. Modules communicate on the single CAN bus at 250 kbit/s. Modules are powered from central power distribution box which splits the battery power, therefore every module has it's own power converter and protection.

Power source

Power divider box Motor controller power bars

Motors and other electronics are powered from single Turnigy 6-cell 1800 mAh 40C LiPo battery. At first we used larger battery, but weight limit forced to use smaller one. Power is distributed to motors with 1mm thick copper bars. Bars are soldered perpendicularly on 2-layer PCB on both sides. At cross-sections 3 mm copper wire is used as via to other layer. Battery connector is directly soldered to the bars. Bars which connect with motor controllers have slots for copper bolts. Bars are tightly pressed on the motor controller PCB with washer and nut. To power side controllers and main controller we use just wire.

Power distribution board also routes the CAN bus between modules. By specifications CAN bus should be non-branching line with terminators on both ends, but as this wasn't possible to achieve and it actually doesn't matter much on such small distances, we used star network configuration instead. Only one 120r terminator was placed in the middle of the bus on power distribution board. Later CAN signal observations with oscilloscope didn't show any anomalies due to star configuration.

Main controller

Main controller

Renesas RX62N (R5F562N8BDFP) MCU is controlling the whole robot on main controller board. RX was chosen because of high performance and floating-point CPU because long term plan was to perform odometry calculations with floating point numbers. Main controller board has two forward-looking Sharp GP2Y0D340K 40 cm digital infra-red distance sensors and two OPB606B line sensors as back-up and additional sensors beside side-controller sensors. They were used at beginning when side-controllers weren't ready. Sockets for MaxSonar®-EZ4 are also on the board and the plan was use the UART interface to get their reading, but as a bad surprise it turned out that EZ4 UART signal is inverted (dooh…). As an experiment 4 equally spaced 3D accelerometers were added with plan to calculate the angular movement out of them. Unfortunatelly, so far there hasn't been time to actually do something useful with them. At that time, at 2010 there wasn't cheap enough digital 3D gyro available, today there is, so it's bit unclear whether to spend time on accelerometer math or simply go for gyro. The list continues - there are two buttons, two RGB LED's, speaker, Bluetooth Bee, SPI and USB pinheader also on board. SPI connector is today used by IR demodulator to receive RC5 packets that are used in RobotChallenge remote controls.

Motor controller

Motor controller assembly

BLDC motor controllers are custom built and fit to the motor mounting brackets. Motor with motor controller is mounted on the framework with two bolts, two power bar nuts and CAN connector. On last robot we had an ugly hairy wire mess between motors, controllers and battery which took half of the space, so this time we really did it as good as possible. Actually, this way the motor wires are as short as possible, so there's less EMI also. dsPIC30F4012 MCU is used to control the motor with IRS2336DSPBF driver. IRFSL3806PbF N-MOSFET's are in use because of good balance of low gate charge, low Rds, safely high drain voltage and large enough drain current. On first motor controller prototype several drivers burned when windings were incorrectly modulated, but after supplying driver with external bootstrap diodes it survived everything.

There was lack of space on motor controller to use large power capacitors, so we used many SMD capacitors. Actually all of the component rated voltages are just in case 2-4 greater than 24 V. MCU power has LC filter and protection diodes. One of the biggest problems still remaining is the 5 V voltage regulator which gets hot because 80% of the energy converts to heat. That's why we kept the dsPIC clock rate as low as possible (no PLL, directly 8 MHz) and that put tough requirements for software in order to have fast phase modulation. DC/DC converter would be quite expensive for every motor controller on the other hand.

Side controller

Side controller and extension boards (without wire connector)

Side controllers are placed above wheels but there are 3 extension board connected to them. There's the same dsPIC30F4012 on board as on motor controller to keep the BOM list shorter and have common software components. One side controller contains 4 Sharp GP2Y0D340K 40 cm IR sensors and 3 line sensors. The line sensors are place in triangle and the intention is to get the alignment on border line. First side controllers didn't have RC filter on distance sensor outputs and lot of false readings occured during rapid motor (de)accelerations. On second version we added 1k 100nF RC filter which worked well. Actually, on first version, there were fifth and sixth distance sensor which looked a little down, to detect the lowering edge of the ring, but that didn't work well because the sensors were dependent on the reflection intensity and as two beams of edge and opponent distance sensors collided they both gave oscillating false readings.


Motor controller

Motor controller software is written in C for MPLAB dsPIC compiler. Tasks that motor controller performs are motor phase modulation according to Hall effect sensors, controlling the desired speed with PID controller and communicating on CAN bus. Most time-critical task is phase modulation because that's what defines maximum speed and getting maximum torque out of motor. Trapezoid modulation is used within the Hall sensor transition interrupt (which is the highest priority task). One thing to note about trapezoid modulation is that it doesn't get 100% out of motor (according to Maxon the torque ripple is 14%). Sinusoidal modulation would be better but that requires encoder. Although Tinavile doesn't have encoders, Hall sensors are used for rotation direction and speed calculation. As EC-i motors have 7 poles it makes total of 7 * 3 * 2 = 42 transitions per rotation. To determine the rotational direction, a lookup table was created in the code that contains all the combinations of previous and current Hall sensor states. “Cells” of that table contain the rotational direction from -2 to +2 steps. ±2 steps could happen when MCU doesn't detect one intermediate transition. Here's the rotational direction table from source code:

// ----------------------------------------------------------------------------
// Here's the motor direction of rotation lookup table based on
// previous and current Hall sensor states:
// Legend;
//   0     - invalid hall state
//  -1, -2 - clockwise direction 1 or 2 steps
//  +1, +2 - counter clockwise direction 1 or 2 steps
// ----------------------------------------------------------------------------
const signed int HallToDirectionLookupSimplified[8][8] =
  New:   A   0   1   0   1   0   1   0   1
         B   0   0   1   1   0   0   1   1
 Prev:   C   0   0   0   0   1   1   1   1
   CBA                                     */
/* 000 */ {  0,  0,  0,  0,  0,  0,  0,  0 },
/* 001 */ {  0,  0, +2, +1, -1, -1,  0,  0 },
/* 010 */ {  0, -2,  0, -1, +2,  0, +1,  0 },
/* 011 */ {  0, -1, +1,  0,  0, -2, +2,  0 }, 
/* 100 */ {  0, +2, -2,  0,  0, +1, -1,  0 },  
/* 101 */ {  0, +1,  0, +2, -1,  0, -2,  0 },  
/* 110 */ {  0,  0, -1, -2, +1, +2,  0,  0 },
/* 111 */ {  0,  0,  0,  0,  0,  0,  0,  0 }

Rotation speed is calculated by summing up transition times and transition counts within transition interrupt. Time consuming math (like divisions) are done on lower-priority 500 hz periodic interrupt. To get the speed, transition count is divided by total transitions time period and multiplied by constant factor to get units in rpm's. Luckily, motor rpm is a good unit to use in signed 16-bit math because the actual rpm values cover almost all possible variable values. If no transitions occur within periodic task, then half of the previous speed is used as speed. As final step, average of last two periodic task speed is calculated. Speed information is fed into PID controller that operates on the same periodic task as speed calculation. The factors of PID were found by trial and error method (kP = 2, kI = 2, kD = 0, Imax = 16000 rpm). Robot drives quite well with motor speeds down to ~200 rpm (it's ~14 rpm on wheel), below that it is not so smooth anymore.

Desired motor speed is received from CAN bus. To distinguish commands of all 4 motors, each one of them (and actually every electronic module) has its own ID which is stored in internal EEPROM. Speed request messages are sent with 5 ms period which is as frequent as possible on 250 kbit/s rate. Request messages have 1 s fail-safe timeout after which motor is stopped automatically. With same 5 ms period motor controller sends out status message which contains current speed, current consumption, heartbeat counter and fault flags.

Main controller firmware

Main SW concept

For main controller programming we use Renesas HEW IDE together with its RX compiler which has 128 KB Code Flah limit, but that's more than enough. Software is written in C++ and a custom preemptive real-time scheduler is used to run tasks. MCU is operating at 96 MHz and scheduler at 1 kHz. Although Renesas has written a peripheral library called RPDL, we use direct register access because it's very simple with address mapped structures that MCU header file provides. Inter-task communication takes place through common data layer. Data layer is basically a group of different type of parameter arrays which are read and written through common functions. Having a common data repository means that it's possible to observe the internal status changes over wireless interface. If needed, the communication task can transmit the data layer parameter which have changed over the Bluetooth to the computer and vice versa. For example, PC remote control writes request to data layer where tasks get them.

To make software upgading simpler and faster we created a XModem bootloader for RX62N. On PC side we use TeraTerm, which supports XModem Send, to load the code into the robot.


"Algorithm" of Tinavile

CaseCatcher is the special PC software tool which we created to edit Tinavile (and other new sumo robots) main controller data layer parameters and do the “algorithm” programming. Algorithm is actually a list of input and output conditions which are called cases. The firmware contains a simple (yet) engine that compares the actual input conditions to the ones described in the case list. If input conditions match then output values are set. The input and outputs are any one of the data layer parameters. For example, obstacle sensor and line sensor can be the inputs and motor speeds can be outputs. Therefore the data layer is not just used for inter-task communication and debugging but also for flexible algorithm programming. Although it naturally looks like a stateless algorithm system, it is possible to create data layer parameters like “robot state”, “search direction” and so on and these can be used inside cases to have a algorithm with state memory. Input conditions use all the basic boolean and integer comparison operation like ==, !=, <, >, ⇐ and >=. Yet, there are some limitations like no possibility to set output value which is relative to the input value.


CaseCatcher has it's own file format but it creates C++ source code. For example, here's the fraction of the cases list that it creates:

StateCase psCases[] = 
	{{ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, EQ(1), ANY, ANY, EQ(1), ANY }, {ST(10), ST(10), ST(-7000), ST(-7000), ST(1000), NC }},
	{{ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, EQ(1), ANY, ANY, ANY, ANY, EQ(1), ANY }, {ST(11), ST(20), ST(2000), ST(-7000), ST(1000), ST(1) }},
	{{ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, ANY, EQ(1), ANY, ANY, EQ(1), ANY, ANY, ANY, ANY }, {ST(12), ST(20), ST(-7000), ST(2000), ST(1000), ST(-1) }}

And here's the “engine” generated by CaseCatcher that processes cases in every algorithm task:

for (i = 0; i < NUM_CASES; i++)
		Compare(&psCases[i].input.Strategy,                   pDL->Get<DLUByte>(DLParamStrategy)) &&
		Compare(&psCases[i].input.State,                      pDL->Get<DLUShort>(DLParamState)) &&
		Compare(&psCases[i].input.StateTimer,                 pDL->Get<DLUShort>(DLParamStateTimer)) &&
		Compare(&psCases[i].input.SearchDirection,            pDL->Get<DLSByte>(DLParamSearchDirection)) &&
		Compare(&psCases[i].input.ObstacleSideRight,          pDL->Get<DLBool>(DLParamObstacleSideRight)) &&
		Compare(&psCases[i].input.ObstacleSideLeft,           pDL->Get<DLBool>(DLParamObstacleSideLeft)) &&
		Compare(&psCases[i].input.ObstacleRearRight,          pDL->Get<DLBool>(DLParamObstacleRearRight)) &&
		Compare(&psCases[i].input.ObstacleRearLeft,           pDL->Get<DLBool>(DLParamObstacleRearLeft)) &&
		Compare(&psCases[i].input.ObstacleFrontRightSide,     pDL->Get<DLBool>(DLParamObstacleFrontRightSide)) &&
		Compare(&psCases[i].input.ObstacleFrontLeftSide,      pDL->Get<DLBool>(DLParamObstacleFrontLeftSide)) &&
		Compare(&psCases[i].input.ObstacleFrontRightStraight, pDL->Get<DLBool>(DLParamObstacleFrontRightStraight)) &&
		Compare(&psCases[i].input.ObstacleFrontLeftStraight,  pDL->Get<DLBool>(DLParamObstacleFrontLeftStraight)) &&
		Compare(&psCases[i].input.ObstacleFrontRightCorner,   pDL->Get<DLBool>(DLParamObstacleFrontRightCorner)) &&
		Compare(&psCases[i].input.ObstacleFrontLeftCorner,    pDL->Get<DLBool>(DLParamObstacleFrontLeftCorner)) &&
		Compare(&psCases[i].input.LineFrontRight,             pDL->Get<DLBool>(DLParamLineFrontRight)) &&
		Compare(&psCases[i].input.LineFrontLeft,              pDL->Get<DLBool>(DLParamLineFrontLeft)) &&
		Compare(&psCases[i].input.LineCornerRightC,           pDL->Get<DLBool>(DLParamLineCornerRightC)) &&
		Compare(&psCases[i].input.LineCornerRightB,           pDL->Get<DLBool>(DLParamLineCornerRightB)) &&
		Compare(&psCases[i].input.LineCornerRightA,           pDL->Get<DLBool>(DLParamLineCornerRightA)) &&
		Compare(&psCases[i].input.LineCornerLeftC,            pDL->Get<DLBool>(DLParamLineCornerLeftC)) &&
		Compare(&psCases[i].input.LineCornerLeftB,            pDL->Get<DLBool>(DLParamLineCornerLeftB)) &&
		Compare(&psCases[i].input.LineCornerLeftA,            pDL->Get<DLBool>(DLParamLineCornerLeftA))
		if (psCases[i].output.State.ulOperation == ACT_OP_SET)
			pDL->Set<DLUShort>(DLParamState, (DLUShort)psCases[i].output.State.slParam);
		if (psCases[i].output.StateTimer.ulOperation == ACT_OP_SET)
			pDL->Set<DLUShort>(DLParamStateTimer, (DLUShort)psCases[i].output.StateTimer.slParam);
		if (psCases[i].output.MotorsLeftRequestSpeed.ulOperation == ACT_OP_SET)
			pDL->Set<DLSShort>(DLParamMotorsLeftRequestSpeed, (DLSShort)psCases[i].output.MotorsLeftRequestSpeed.slParam);
		if (psCases[i].output.MotorsRightRequestSpeed.ulOperation == ACT_OP_SET)
			pDL->Set<DLSShort>(DLParamMotorsRightRequestSpeed, (DLSShort)psCases[i].output.MotorsRightRequestSpeed.slParam);
		if (psCases[i].output.MotorAcceleration.ulOperation == ACT_OP_SET)
			pDL->Set<DLUShort>(DLParamMotorAcceleration, (DLUShort)psCases[i].output.MotorAcceleration.slParam);
		if (psCases[i].output.SearchDirection.ulOperation == ACT_OP_SET)
			pDL->Set<DLSByte>(DLParamSearchDirection, (DLSByte)psCases[i].output.SearchDirection.slParam);
		// Got match

There are input and output macros and Compare function also tightly related with engine, but they do quite simple things - the inputs and outputs in cases are basically structures with operation and operand members and Compare function just uses the right operation to perform the compare matching. The performance of the system is good enough because algorithm and other firmware tasks fit in the 1 ms scheduler period. Yet we have ideas how to make the case matching smarter and faster.

Competition results

  • BRS 2011 Klaipeda Spring cup - place unknown, didn't make to the quarter finals.
  • Robotika 2011 in Riga - 6th place out of 7 robots.
  • Robotic Arena 2011 in Wroclaw - 2nd place.
  • RobotChallenge 2012 - just out of quarder finals (total of 28 registered robots).
  • BRS 2012 Riga Cup - 3rd place out of 7 robots.


Take a look at more images taken here. A fancy 3D image from Robotic Arena here.



Team members

Further plans

There are some ideas to update Tinavile software but we will mainly focus on building a new two-wheeled robot Scythe ;-)

Manual (kasutusjuhend)

projektid/voistlusrobotid/standardsumo/tinavile.txt · Last modified: 2022/04/28 20:24 by