Indoor spaces have no satellite view and no street layer, so wayfinding inside a large venue has to be built rather than borrowed. That means two products at once: a navigable 3D map for visitors, and an authoring tool good enough that producing a new venue is a day's work rather than a project.
Architecture
A 3D map visitors navigate in the browser, and the editor used to author it. Proprietary commercial software, so what follows describes the shape of the work and my part in it rather than how any of it is built.
TypeScript
Three.js
React
WebGL
In Development
Media Gallery
Yalla Aloha
Problem
A travel platform with two web front ends over one API, where the schema changes constantly because the product is still being discovered. The risk is not writing the code — it is the API and the two clients drifting apart quietly, and nobody noticing until a page breaks in front of a customer.
Architecture
A NestJS service on Fastify over PostgreSQL, whose OpenAPI document is generated, linted against a custom ruleset, and diffed against the previous version to catch breaking changes before they merge. Both React front ends generate their client from that document, and CI fails if the committed client no longer matches it.
TypeScript
React
NestJS
PostgreSQL
Bun
Dual-view
Media Gallery
ziadgaser.sh
Problem
This site is two complete visual identities over one set of content, with a hidden terminal as the only door between them. The hard part is not the second skin — it is that both live in the same document at the same time, so every root-level assumption a page normally gets to make stops being true.
Architecture
One Tailwind theme whose token values are overridden under a `data-view` attribute, so the same utility classes re-skin at runtime. The default view is static HTML with no framework on the page; the second mounts from a lazy chunk on transition. Shared behaviour lives in framework-agnostic DOM modules both sides call, so neither view has to adopt the other's runtime.
Two audiences with almost nothing in common — travellers browsing and booking trips, agencies building and selling them — but one product, one API and one design language. Building them as separate apps duplicates everything; building them as one app serves neither well.
Architecture
Two Flutter apps over a private shared package that carries the design system, networking and domain layer, consumed through a package registry at a pinned version rather than a branch. Behind them a NestJS service whose OpenAPI document generates the typed Dart client both apps call, so a contract change surfaces at compile time instead of in production.
Duet's obstacle course is hand-authored and finite. Rebuilding that feel as an endless runner meant generating the course at runtime instead — every chunk guaranteed passable, never visibly repeating, and steadily harder, with no level designer in the loop.
Architecture
Weighted prefab pools pick each chunk, placed against the previous chunk's measured world extents so spacing stays clearable, and spawned only once the previous one enters frame. One speed scalar drives fall rate, rotation, difficulty mix and scoring together. The HUD is the screen border itself — a line tracing the edge, its legs proportioned from real screen dimensions so a point of health costs the same pixels on any aspect ratio.
Time manipulation is usually a cutscene or a rewind button. Here it is the verb you play with — the thing you spend, plan around and can break. Making that legible on a hex grid, turn by turn, is the whole design problem.
Architecture
A step-based strategy roguelike in Unity on a custom hexagonal tile system, where each action advances the world one step and time itself is the resource being manipulated. Pixel art iterated live through an Aseprite hot-reload pipeline.
A one-versus-one tactical duel where nothing is left to chance: no dice, no shuffled draw order, no hidden randomness. Both players see the same board and the same options, so a loss is always a decision rather than a roll — which puts all the design weight on the cost of each move.
Architecture
A deterministic card duel on a grid, built in TypeScript. Every attack, card and ability spends from one shared pool of move points, so a turn is an allocation puzzle rather than a sequence of free actions, and each hero runs on a different resource entirely.
An endless runner needs endless level, and hand-placing platforms one at a time does not scale past the first minute of play. The interesting problem was not the running — it was building the tools that make laying out track fast enough to be worth doing.
Architecture
A 3D endless runner in Unity where the level scrolls continuously past the player, built alongside a set of custom Unity editor tools for authoring track — placing platforms, slicing and combining them, and generating spreads.
Geomagnetic storms disturb satellites, navigation and power grids, and the warning available is short. Forecasting the Kp index a day ahead from solar wind measurements is a noisy, heavily imbalanced sequence problem — quiet hours vastly outnumber stormy ones, and the storms are the only part anyone cares about getting right.
Architecture
A stacked GRU trained on decades of hourly OMNI2 solar-wind data, reading a 24-hour window and emitting the next 24 hours of Kp in one shot. Which of the dataset's columns it reads was itself decided by particle swarm optimisation — the subject of the IEEE paper. Exported to TensorFlow Lite so inference runs from a small runtime, and served behind Flask to a React front end that charts the forecast alongside the conditions that produced it.
Closing the loop from a camera frame to physical motion, on hardware with no feedback of its own. A vision model can say which fruit is ripe; a six-servo arm can only be told angles. Everything between those two facts had to be built, wired and powered.
Architecture
Three stages joined by a serial link: a Python front end that finds the arm by handshaking every serial port on the machine, a hosted vision model that classifies each captured frame, and an Arduino state machine that turns a single command byte into a sequenced pick-and-place. Poses are declared as named angle constants and motions compose them, so the arm's vocabulary is data rather than control flow.
Reading Egyptian number plates, which are set in Arabic letters and Arabic-Indic digits rather than Latin characters. No off-the-shelf dataset existed, and the alphabet turned out to be a harder problem than the model — the same digit has two Unicode code points that render identically.
Architecture
A convolutional trunk over a 128x64 grayscale crop, splitting into seven parallel softmax heads — one per character position, with a blank class for plates shorter than seven. Fixed-width classification rather than sequence decoding, which suits a format whose length is bounded and whose characters never touch.
Shipping complete products rather than features — mobile, web, and the services behind them, from first commit to store listing.
Real-Time 3D
Interactive 3D on the web and in engines: navigation, rendering, and the authoring tools that make content for it.
Games & Design
Designing and building games — mechanics first, with tooling so the content pipeline keeps up with the ideas.
04
The Narrative
Games came first. I wanted to make the things I was playing, which meant learning to program — and that turned into work that keeps widening: mobile apps, web platforms, machine learning, a robot arm, 3D indoor navigation.
Looking at my own projects, I notice I keep ending up on the whole problem rather than one layer of it. I wired the arm and trained the model that told it what to pick up. I build the map editor and then use it to build the maps. It isn't a strategy — the interesting part is usually the seam between two things nobody owns.
The next thing on that list is a Master's in Computer Science. Most of what I know came from building things, and I would rather have the theory underneath that than keep deriving it as I go.
Outside of the digital void, you can find me exploring the physical world through outdoor photography,reading a book, or immersed in the complex rhythms of metal music.
05
Initiate Protocol
Got something you want built?
No pitch needed — a rough idea is enough to start.
Indoor wayfinding for large venues: a 3D map visitors navigate, and the editor my
team uses to author it. Commercial software at my current employer, in production.
My role
I am the current lead developer on both the navigator and the editor, and the
person who builds new venues — taking a floor plan and turning it into a finished,
navigable map.
The editor is the piece I have shaped most — most of it as it exists today is my
work — and I have been the primary contributor to the product since I joined.
Turning a floor plan into a map
The part of this I find most interesting is not the rendering, it is that map
production is a craft with a deadline. A floor plan arrives as a drawing made for
builders, not for software: inconsistent conventions, features that matter to a
visitor left implicit, and no notion of how a person actually moves through the
space. Getting from that to something a stranger can navigate involves a lot of
judgement that cannot be automated away.
Doing that work repeatedly is also what makes me a useful person to be building
the editor. Every awkward step in producing a venue is one I have hit myself, so
the tool gets shaped by the constraint of actually using it — which is a different
and better feedback loop than gathering requirements from the people who do.
On the detail
This is proprietary software belonging to my employer, so there is deliberately
nothing here about how it works, and no link, because there is nothing public to
link to.
That limits what this page can say, and I would rather it be thin and accurate
than detailed and indiscreet. Happy to talk about the engineering in a setting
where that is appropriate.
A web-based travel platform — a customer app, an admin app for operators, and the
API behind both. Not in production yet.
I am not the primary author here; a colleague leads it and wrote most of it. My
contribution is roughly 18,000 lines still in the codebase across the three
repositories, and it has a particular shape worth describing.
Working vertically
Almost everything I shipped on this crosses all three repositories in one go.
Adding time-of-day and coordinates to activities meant the Prisma schema and the
catalog module, then regenerating the API client, then the admin form to enter
them and the customer page to display them, then the Arabic and English strings
for both. The same is true of package-only hotels, of per-night pricing with a
breakdown when nightly rates vary, of configurable WhatsApp notifications with
trip-date-aware scheduling, and of the booking expiry scheduler.
That is a different kind of contribution from owning a layer. It means the work
is bounded by a feature rather than by a repository, and it only works because
the seam between those repositories is enforced rather than trusted.
The seam is enforced, not trusted
This is the part of the project I would point at, and it is the sharpest version
of a pattern I have used before.
The API’s OpenAPI document is generated from the service itself, then put through
three gates. Spectral lints it against a project ruleset — including a custom
rule that every operation declares a 2xx response schema — and the lint runs at
--fail-severity=hint, so even advisory findings block. oasdiff compares the
document against the previous version and fails the build on a breaking change,
which turns “we broke a client” from a discovery into a merge conflict. The
normalised result is then published as an artifact.
Each front end regenerates its typed client from that artifact — and then runs
git diff --exit-code against the committed client. If the generated output
differs from what is in the repository, CI fails. There is no way to merge a
front end whose idea of the API has quietly diverged from the API.
Two front ends and one backend changing daily is exactly the situation where
drift is invisible until it is expensive. Here it is a red build.
The rest of the stack
Bun as the runtime and package manager, Biome instead of ESLint and Prettier, and
Fastify throughout — both as the NestJS adapter and as the server that renders
the React apps, so server-side rendering is a Fastify server the team owns rather
than a framework’s opinion. Auth0 for identity, i18next for Arabic and English
across both apps, Prisma over PostgreSQL.
Testing is more serious than a pre-launch product usually manages: Vitest and
Testing Library for units, Playwright for end-to-end, Mock Service Worker so
front-end tests run against the API contract rather than a live server, and
axe-core wired into Playwright so accessibility violations fail the suite
instead of waiting for an audit that never gets scheduled.
The page you are reading. Two identities over one content source, joined by a
terminal that most visitors will never find.
One theme, two palettes
Both designs use the same token names with different values — primary is a
pale blue in one and pure white in the other, body text is Inter at 16px or
JetBrains Mono at 14px. Tailwind v4 compiles utilities down to var(--token)
references, so redefining those variables inside a [data-view='retro'] block
re-skins every existing class at runtime. No duplicate class lists, no second
config, and no component that knows which palette it is wearing.
The cost is that @theme and @utility are only processed in the file that
imports Tailwind, so the stylesheet has a single entry point that pulls in every
layer. Splitting them into independently-imported files silently dropped every
custom utility from the production build while dev kept working.
Both views share one document
This is the constraint that shapes everything else. The default view is never
unmounted — it is hidden and left in place — while the second mounts alongside
it. So at any moment the DOM may hold two copies of the same content, and every
assumption that a page has one of something breaks:
Duplicate element ids. Two #portfolio sections exist, and the browser
resolves an anchor to whichever comes first — which was the hidden one, so nav
links changed the URL and scrolled nowhere. Ids are now parked on a data
attribute while a tree is hidden.
Section discovery. The terminal’s ls listed sections from both views at
once. It filters on real visibility now, and the rescan waits for the outgoing
tree to actually leave the DOM rather than firing on the state change.
Root-level styling. The scrollbar belongs to the document, not to a view,
so it keys off html:has([data-view='retro']) instead of inheriting.
Full-bleed chrome. The CRT vignette sits below the header and footer and
above the content. Placed above everything it dimmed the header’s leftmost and
rightmost elements — the two darkest corners of a radial gradient — and the
chrome read as a different colour from the design.
Each of those looked like a separate bug. They are all the same one.
What actually ships
The default view is .astro with no framework on the page; the second is a
Svelte island imported only when the transition fires. Measured against the
production build, first load is 28 requests and about 67 KB of JavaScript —
two small islands and the terminal’s core. The second view’s 36 KB chunk, its
content.json, and its three fonts arrive only on transition, so a visitor who
never opens the terminal never downloads them.
That split is also why the content is fetched as JSON rather than duplicated
into the HTML: the second view is rendered client-side, and inlining every
string twice would have cost more than the chunk does.
The terminal is the door
A small OS-flavoured terminal (ported from a friend’s project with permission)
provides the only route between views. It has a shell with history and
tab-completion, a section registry that ls and cd operate on, and a virtual
file registry — which is the whole trick. ziadgaser.sh appears in ls only in
one view and revert.sh only in the other, so the listing doubles as a state
indicator and there is no way to run the wrong one. cat prints either.
Running the script triggers a kernel panic: chromatic aberration, shake, and
horizontal tear bands that invert what is behind them. The way back is
deliberately asymmetric — a systemd-style boot log rather than a second glitch,
because glitching into the stable view would contradict what that view is
supposed to represent.
Sharing behaviour without sharing a runtime
The lightbox, the carousel and the long-form reader are plain DOM modules, not
components. A static view that ships no framework and a Svelte view can both call
a function; neither can import the other’s components without pulling in its
runtime.
The reader is the sharpest case. Project bodies are MDX, which the content layer
marks deferredRender — there is no stored HTML, and the render helper returns
an Astro component the Svelte side cannot use. The options looked like shipping a
markdown parser to the client or duplicating every article into the JSON payload.
Neither was necessary: because both views share one document, the bodies are
rendered to real HTML once at build time into inert <template> elements sitting
outside both view trees, and either side clones from them.
A commercial travel platform: a traveller app and an agency app on iOS, Android
and — for agencies — the web, a NestJS backend serving all of them, and a
marketing site. Live in production and in both app stores. I am no longer on the
project and do not maintain it.
I led the development team. I was the primary author on the Flutter side — the
shared package and both apps — and I stood the backend up: the initial service,
the schema, the container setup, the deployment pipelines and secret management,
after which I directed it rather than wrote most of it, and the codebase has
moved on since. The marketing site is mine too. I also owned the release process:
the four store listings above are ones I submitted.
Two apps, one shared package
The two audiences share a design system, a domain model and every piece of
networking, but almost no screens. So the shared half lives in its own package —
theming, localisation, the DI container, the networking layer, and the domain and
data layers for the features both sides touch: agencies, packages, payments,
conversations, reviews, documents, maps, notifications.
The part worth pointing at is how the apps consume it. Not a path dependency, not
a git branch — it is published to a private package registry and pinned by
semantic version. That costs a release step every time the shared library
changes, and buys the thing a monorepo makes awkward: an app can sit on a known
version, upgrade deliberately, and be bisected against a specific one when
something breaks. With a branch dependency every app rebuild is a moving target.
The API contract generates the client
The backend documents itself with OpenAPI, and that document produces a typed
Dart SDK, published as its own versioned package and consumed by the shared
library.
This is the load-bearing decision on the project. A REST client written by hand
against a live API fails at runtime, on a user’s device, as a null where a string
was expected. Generated from the contract, the same mistake is a compile error
before anything ships — and when the backend adds a required field, every call
site that needs updating announces itself immediately.
One codebase, three platforms
Agencies get the same Flutter codebase as an installable web app as well as iOS
and Android — branded manifest, light and dark splash screens, service-worker
push — deployed to its own subdomain by its own pipeline. Agency staff work at
desks; asking them to do inventory management on a phone would have been the
wrong call, and building a second web front end to avoid that would have meant
maintaining the same screens twice.
The traveller app stays mobile-only, because travellers do not.
Shipping it
The backend deploys per environment straight from the branch name — development
and test automatically, production behind a manual gate — with secrets pulled
from a secret manager at deploy time and never stored in the repository or the CI
configuration.
Underneath: MongoDB through Prisma, Redis, RabbitMQ for asynchronous work, MinIO
for object storage, Socket.IO for chat, Firebase for push, and headless Chrome
rendering booking vouchers to PDF. On the mobile side, Sentry for crash reporting
and over-the-air Dart code push, so a fix can reach users without waiting on
review.
Getting four listings through App Store and Play review — signing, entitlements,
privacy declarations, staged rollouts and the rejections that come with a booking
app handling payments — is its own body of work, and one I owned rather than
handed off. It is the part of shipping that no amount of clean architecture makes
go away.
What that adds up to
Roughly 110,000 lines of Dart across the three Flutter repositories, a backend of
around thirty modules, and about two years of continuous work with a team — the
longest-running thing I have built, and the only one on this page where the cost
of a bad architectural decision was paid by real customers rather than by me.
It is still running, and it is no longer mine. Which is the real test of the
decisions above: the version pinning, the generated client, the environment
split — those were bets that the next person would be able to work in it without
me there to explain it.
A solo take on Duet, rebuilt around procedural generation and a health system
that changes what a mistake costs.
Generating an endless course
Obstacles come from two weighted pools, normal and hard. Each prefab is repeated
in its pool according to a weight table, so selection probability is data rather
than branching logic — a horizontal bar appears seven times as often as a
vertical one.
The hard pool’s share is not fixed. It scales as baseHardChance × speed², so
difficulty arrives as a curve rather than at scripted thresholds, and the mix
keeps shifting for as long as a run lasts.
Placement is the part that guarantees fairness. Each chunk is positioned from the
previous chunk’s actual world position and half-height plus the larger of the two
prefabs’ padding values, so the vertical gap is derived from real geometry
instead of a tuned constant. Generation is driven by visibility: the next chunk
is requested the frame the current one first becomes visible, which keeps the
spawn rate correct at any speed without a timer to retune. Chunks despawn once
they pass below the camera.
Variation is decoupled from generation. Separate height, width and position
components each pick from an authored set of values on Awake, so five prefabs
cover a much wider space of arrangements than five fixed layouts would.
One scalar for difficulty
Rather than independent difficulty knobs, everything reads from a single clamped
GameSpeed: obstacle fall rate, ball rotation speed, rotating-obstacle angular
velocity, hard-chunk probability, and the score rate — which is exponential in
speed, so late-run seconds are worth far more than early ones. It rises on an
interval, quickly up to a soft cap and slowly past it, up to a hard ceiling.
Departing from the source
Duet ends a run on contact. Here a hit costs 45 HP against a pool that starts at
40 and regenerates 5/second, so survival depends on how recently you were last
hit. If HP remains, the run continues: obstacles are pushed off the top of the
screen, the balls spin back to their starting angle, and play resumes with a
speed penalty. Holding full HP pays a standing speed bonus, which turns the
health bar into a risk dial rather than a life counter.
The HUD is the screen border
There is no health bar. Health is drawn as a single LineRenderer that traces the
edge of the screen: it fills rightward along the bottom, turns up the right side,
then runs back across the top, so a full pool is a complete gradient loop framing
the play area and a near-empty one is a stub in the bottom corner.
The mapping is what makes it hold together. Rather than splitting HP into three
fixed thirds, each leg’s share is computed from the screen’s real dimensions —
the bottom gets width/2 ÷ (width + height), the side gets
height ÷ (width + height) — so a point of health is the same number of pixels
travelled whichever leg it lands on, on any aspect ratio. Those boundaries are
recomputed every frame against the current camera bounds, so rotating the device
or resizing the window re-fits the whole thing without a reload.
The result is a HUD that reports continuously while occupying no space inside the
playfield, in a game where the playfield is the entire screen and any panel would
have been an obstacle.
Full health as spectacle
Crossing 100 HP is the game’s one flourish, and it fires on five channels at
once: a full-screen radial pulse (polar coordinates through a modulo for
concentric rings, wave count and speed exposed as shader properties) spikes to
half intensity over 0.125s then eases to a low ambient throb; the music pitches
to 1.3 and settles back to 0.95; the score readout tints violet and starts a
slow scale pulse; every background tile begins breathing on its own randomised
period and amplitude; and the border line completes its loop. Dropping below 100
unwinds all of it over a second.
The render feature is only active inside that window, so the extra full-screen
pass costs nothing during ordinary play.
Presentation and platform
Portrait, with touch on screen halves and arrow keys on desktop, resolved from
SystemInfo.deviceType. The camera recomputes its orthographic size each frame
against a 9:20 reference so the playable width is identical across aspect
ratios. Rendering is URP’s 2D renderer, with the pulse above injected as a custom
blit pass. Trails, background animation, audio and inverted controls are all
toggleable and persisted in PlayerPrefs; the target frame rate is matched to the
display’s refresh rate.
Pausing sets timeScale to zero, but the presentation deliberately does not stop
with it — every tween and coroutine that drives the interface runs on unscaled
time, so the background keeps turning and the menus keep animating behind a frozen
playfield. It is a small discipline applied consistently, and it is the difference
between a paused game and a game that looks like it crashed.
Third-party: DOTween for tweening, and Cyanilux’s URP blit render feature to
schedule the full-screen pass — the shader graph driving it is mine.
If you want a playable version, feel free to contact me. I would be happy to share a build.
A pixel art step-based strategy roguelike on a hexagonal grid: manipulate time to
defeat enemies and solve puzzles — but spacetime is fragile. A collaborative build
with Nour Gaser.
Most games that advertise time manipulation give you an undo button — a way to
retract a mistake. This one makes time the material you act on. The world advances
one step per action rather than in real time, so every move is a discrete tick you
can reason about, and manipulating the sequence is the actual play rather than a
recovery mechanic.
The catch built into the premise is that spacetime is fragile: the power is not
free, and using it carelessly is its own failure mode. That is what stops a rewind
mechanic from trivialising a roguelike, which is the usual problem with combining
the two.
Hexes, by hand
The grid is a custom hexagonal tile system rather than an off-the-shelf one. Hex
grids are deceptively awkward — three axes of movement, coordinates that do not
map cleanly onto a rectangular array, and neighbour logic that has to be right
before anything built on top of it can be trusted. Doing it directly means the
turn and time logic sits on foundations that behave exactly as the design needs.
Iterating art at the speed of code
The detail I like most is a tooling one: an Aseprite hot-reload pipeline, so
sprites update in the running game as they are drawn. Pixel art is normally a
save-export-reimport-rebuild loop, which is slow enough that you stop
experimenting. Collapsing it to a live reload means the art gets iterated with the
same tightness as the code — which is exactly the kind of unglamorous tooling
investment that shows up in how the finished thing looks.
The tactical-combat skeleton of XCOM, wearing the stat system of League of
Legends, dealt out as a Hearthstone-style card game. A collaborative build
with Nour Gaser.
There is no randomness anywhere. No dice rolls, no shuffled deck order, no
percentage-to-hit. Both players are looking at complete information, which means
every loss traces back to a decision instead of a roll — and it means the game
cannot lean on variance to create tension.
That removes the usual escape hatch. A card game with randomness can make a
mediocre turn feel exciting; this one has to make the choice interesting, which
is a much harder standard and the reason the design has been iterated on for
years rather than shipped quickly.
One budget for everything
The mechanic that carries it is a single shared pool of move points. Moving
across the grid spends from it. Playing a card spends from it. Using an ability
spends from it. Attacking spends from it.
So a turn is not a sequence of things you are allowed to do, it is an allocation
problem: closing distance costs the same currency as the attack you close in
order to make. Positioning stops being free, and the grid becomes part of the
economy rather than a backdrop for it.
Heroes that run on different fuel
Rather than giving every hero the same resources with different numbers, each one
runs on a different engine — move points, HP spent as fuel, gold, Heat, mana.
Picking a hero is picking which economy you want to play, not which stat line you
prefer, and it means two heroes on the same board are effectively playing two
different games at each other.
The current build is a v0.1 demo of a design that has been in progress for over
five years.
A 3D endless runner: dodge obstacles, collect coins, and keep going as the level
scrolls forward. A collaborative build with
Nour Gaser.
The game is a straightforward endless runner. What it was really an exercise in is
Unity’s editor extension API — building bespoke tooling for laying out track
rather than positioning every platform by hand in the inspector.
The editor tools cover placing platforms, slicing them apart, combining them back
together, and generating spreads of them at once. Which is the honest shape of most
game work: the player-facing feature is a few hours, and then you spend far longer
building the thing that lets you produce enough content for it to be a game rather
than a tech demo.
Unfinished, deliberately listed
This one was never finished, and it is here as a demo rather than a product. The
editor tooling is what came out of it — knowing how to extend the environment you
work in, rather than only working inside it, transfers to every project afterwards
whether or not the game it was built for ever shipped.
My bachelor’s graduation project, built with a team — I worked across all of it
rather than owning one slice. The feature-selection method behind the model was
published as
Feature Selection for Geomagnetic Storm Forecasting Using Particle Swarm
Optimisation at the 25th
International Arab Conference on Information Technology (IEEE ACIT 2024), on
which I am first author.
What it predicts
The target is the Kp index, the planetary measure of geomagnetic
disturbance. The source data is OMNI2, NASA’s hourly record of near-Earth solar
wind and field conditions going back decades: field magnitude and its components,
the variability of each, plasma ratios, flow angles and proton flux at several
energies.
Kp arrives on a 28-step scale in thirds, which is awkward for a regression head
to aim at, so it is folded to ten integer levels. A storm is Kp ≥ 5 — the
conventional threshold where operators start caring. The model reads a 24-hour
window and emits the next 24 hours in one pass rather than predicting an hour and
feeding its own output back, which keeps errors from compounding across the day.
The model that ships
Two stacked GRU layers of 100 units with 0.5 dropout between them, a 32-unit
dense layer, and a linear head with one output per forecast hour. Adam, MSE,
twenty epochs.
A detail that matters more than it looks: the features are standardised, but Kp
is deliberately left unscaled. It is both an input and the target, so scaling it
would have meant unscaling every prediction on the way out, and the rounding step
that follows would then be operating on reconstructed values rather than the ones
the network actually produced. Outputs are rounded and clipped to the valid 0–9
range, which turns the regression into something with the same resolution as the
index it is predicting.
Choosing the inputs, and the research around it
OMNI2 carries far more columns than the fifteen the final model consumes, and
deciding which to keep is its own search problem — one with no gradient to
follow, since the thing being scored is a subset rather than a continuous
parameter. That is what the paper addresses: particle swarm optimisation
treating a candidate feature subset as a particle’s position, scored by how well
a model trained on it actually predicts.
The repository carries the machinery for twelve further metaheuristics wired to
the same interface — grey wolf, whale, Harris hawks, salp swarm, cuckoo search,
bat, firefly, differential evolution, genetic, Jaya, sine-cosine and flower
pollination — so alternatives could be swapped in and compared on equal terms.
A second, separate line of research sits alongside it and is not in the
deployed site: the Chimp Optimization Algorithm, driven by chaotic maps
rather than uniform randomness, searching learning rate, batch size and epoch
count across four model families in turn — LSTM, GRU, LSTM with an attention
layer, and ARIMA as a classical baseline. Same family of algorithms as the
published work, aimed at a different target: hyperparameters rather than
features.
Serving it
Inference runs through TensorFlow Lite rather than full TensorFlow. For a
container that only ever does forward passes, the interpreter is a fraction of
the size and starts far faster — a real difference on a student-budget host.
The stack is Docker Compose: nginx in front, Gunicorn with three workers behind
it, SQLite holding both the historical conditions and the forecasts, and
container health checks on both services. The front end is React with Vite and
Chart.js, plotting the forecast next to the solar-wind measurements it came from,
with pages explaining what a geomagnetic storm actually is — the audience was
never assumed to already know.
Where the demo stops short
It is a demonstrator, and the honest limits are in the data rather than the
model.
Forecasts are precomputed and stored in a prediction column alongside the
observations, then read back by a single /data endpoint; nothing runs the
network on request. Refreshing daily would mean a live feed of the previous 24
hours of OMNI2 features — including the current Kp, which the model takes as an
input — and OMNI2 is a curated, reprocessed archive published on a lag, not a
real-time stream. A genuinely daily forecast needs a different source for the
recent tail, which was outside the scope of the project rather than an oversight
in it.
A 6DOF arm that watches a fruit through a webcam, decides whether it is ripe,
and drops it into one of two bowls. I built and wired the hardware; the software
was a shared effort.
Finding the arm without being told where it is
A serial project usually starts with a hardcoded port — COM3 on the developer’s
machine, and a support conversation for everyone else. This one discovers the arm
instead. It enumerates candidate ports per platform (COM1..256 on Windows,
globbing /dev/tty* on Linux and macOS), opens each at 9600 baud, sends a single
S, and waits for the Arduino to answer Arduino Ack. The port that replies is
the arm.
That handshake runs on a daemon thread while the Tkinter window animates a
spinner, so the interface stays responsive during the several seconds of probing
that opening a serial port costs. When the acknowledgement lands, the spinner
tears itself down and hands the live connection to the control window.
Poses as data, motions as composition
The Arduino sketch keeps every angle in a named constant, grouped by pose —
default, picking, dropping, after-picking, rotated-left, rotated-right — and each
pose is a function that writes only the joints it changes. Complete motions are
then compositions of poses with delays between them, so pickToLeftBowl reads as
a sequence of intents rather than a list of servo values.
The practical payoff is calibration. Adjusting where the arm reaches for the
fruit means editing one constant, not hunting for a number buried in a motion
sequence. It also keeps the two sorting motions honestly identical: they differ
by exactly one pose, which is the only thing that should differ.
The wiring shows up in the code
initServos writes every joint to its default angle before attaching any servo
to its pin, then attaches them one at a time with a 500ms stagger.
Both halves of that matter, and neither is obvious from software alone. A servo
that is attached before it has a target snaps to whatever the idle pulse width
implies, which on a loaded arm means a jolt against its own weight. Staggering
the attachments then keeps all six motors from drawing startup current at the
same instant — the reason the motors run from a repurposed PC power supply rather
than from the Arduino’s regulator, which could not survive six simultaneous
stalls.
Classification
Frames go to a hosted Roboflow model trained to tell ripe strawberries from
unripe ones, at a 50% confidence floor with 30% overlap suppression. Of whatever
comes back, the highest-confidence detection wins and collapses to one byte on
the wire: 1 sorts right, 0 sorts left. The Arduino’s loop reads that byte
and runs the matching sequence, returning to the default pose afterwards so every
cycle starts from a known position.
What it does not do
Worth being straight about the limits, because they were the right trade-offs for
a build on this timescale and not oversights to gloss over. Inference is a network
round trip, not on-device, so the cycle time is dominated by upload latency and
paced at roughly four seconds. The arm is open-loop: it has no way to confirm it
actually closed on the fruit, and recovery from a missed grab is a human picking
it up. And the camera is reopened every iteration rather than held, which is
wasteful but sidesteps stale-frame buffering.
A solo project: read the characters off an Egyptian number plate, where the
characters are Arabic. Notebooks only — the model was the point, and there is no
interface around it.
Reading, not finding
Worth being precise, because the repository name overstates it. The model takes a
photo already cropped to the plate and returns its characters. It does not locate
a plate within a wider scene, so it is recognition rather than detection —
the second half of what a full pipeline would do.
Seven heads instead of a sequence decoder
The obvious approach to reading characters off an image is CTC, which handles
variable-length output and characters that run together. This does something
simpler: the trunk is four convolutional blocks widening 64 → 64 → 128 → 512,
each with pooling and dropout, feeding a 1024-unit dense layer that then splits
into seven independent softmax heads, one per character slot.
Labels are padded to seven with a + that is a real class the model can predict,
so a six-character plate is trained to output blank in the last slot. Loss is
sparse categorical cross-entropy summed across the heads.
That is a legitimate trade rather than a shortcut. Egyptian plates have a bounded
length and well-separated, embossed characters, so the alignment problem CTC
exists to solve does not really arise — and a fixed-width head is far easier to
train, debug and read the output of. What it gives up is generalising to any
other plate format.
The alphabet was the actual problem
The interesting work is in the character map, not the network.
Arabic-Indic digits exist twice in Unicode: the Arabic block (١ U+0661) and the
Extended Arabic-Indic block used for Persian (۱ U+06F1). For 1, 2, 3, 7, 8 and 9
the two render identically, so an annotator cannot tell which they typed and
the dataset ended up containing both. Left alone, the model would have been
trying to learn two separate classes for one glyph. They are folded onto the same
class — and only those six, because 4, 5 and 6 look genuinely different between
the blocks, so no one confused them.
The same fold applies to the orthographic pairs that vary by hand: alef with and
without hamza (أ/ا), and ya against alef maqsura (ي/ى). Twenty-nine
classes plus a blank, which is fewer than the raw character count in the labels
and deliberately so.
The data
There was no dataset, so it is roughly 2,700 plate photographs collected and
labelled by hand, published on Kaggle alongside the code. Preprocessing is
grayscale and a resize to 128×64. Training set is expanded roughly eightfold with
rotation, shear, small translations and zoom — the kind of variation a phone
camera introduces anyway.
Training stops on a custom callback: it averages validation accuracy across the
seven heads after every epoch and halts once that clears 95%, which it does
inside a few epochs.
What I would change
Three things, none of which the 95% figure makes obvious.
That number is per character, not per plate. Seven heads at ~95% each is a
much weaker claim than 95% of plates read correctly — if the errors were
independent it would be about 70% of plates, and the honest metric for this task
is exact-match on the whole string.
The split happens after augmentation. Augmented copies are generated first
and the train/test split is taken over the combined pool, so variants of the same
photograph can land on both sides. The validation figure is therefore optimistic;
the split should be by source image, before any augmentation.
About 6% of the labels are thrown away. Rows containing a character absent
from the map are dropped, and almost all of those are Latin digits — because
Egyptian plates print the Latin transliteration underneath the Arabic, and
annotators sometimes transcribed that row instead. Mapping 1–9 onto the
existing digit classes would recover 153 images for a few lines of code.