MBA project Archive data, not a production system

Monterey Fisheries: Three NOAA Datasets, One PostgreSQL Database, and Numbers That Held Up

I took commercial fish landings, harbor tide readings, and offshore buoy readings, three public datasets from three publishers in three file formats, and built one PostgreSQL database that can answer a real question: does the catch move with the ocean? Then I tested what the database could and could not prove.

8
tables, 47 columns
57,740
rows loaded
34
rules enforced by the database
3
source formats: CSV, JSON, TXT
5
business questions answered
3
access roles, each tested
Skills:PostgreSQLSchema design and ERDJoins, CTEs, window functionsData cleaningPythonpandasRegression, ANOVARole-based access
The problem

Catch data and ocean data live in different places

A fisheries monitoring program wants to know whether what boats bring to the dock is tied to what the ocean was doing that month. The answer needs data from three publishers that were never designed to fit together.

SourcePublisherFormatWhat it holds
California Fish Market Catch LandingsNOAA CoastWatch ERDDAPCSVPounds landed per species per port per month
Tide gauge 9413450NOAA CO-OPS APIJSONMonthly water level summary for the Monterey harbor
Buoy 46042, Monterey BayNOAA National Data Buoy CenterPlain text, one file per yearHourly wind, wave, temperature, and pressure readings

I anchored everything on one port, Monterey, California, for 1998 through 2002. That window is the full overlap of all three sources. The shared key is place plus month, which meant aggregating hourly buoy readings up to months. That is a design decision with consequences, and later sections show what it did to the results.

CSV landingsJSON tidesTXT buoy files →Python prep and staging tables →PostgreSQL 14, 8 tables →SQL analysis →Statistical tests in Python
The design

Eight tables, eight relationships

Entity relationship diagram of the monterey_fisheries database showing eight tables: ports, species, stations, landings, vessel_trips, tide_months, buoy_months, and buoy_observations, with their keys and relationships
The entity relationship diagram, drawn in pgAdmin from the live database. Click to enlarge.

The structure follows the shape of the data, not a template.

The database enforces 34 named rules itself: 8 primary keys, 8 foreign keys, 7 unique rules, and 11 check rules, plus 31 columns that cannot be left empty. Naming followed one convention throughout, plural lower case table names and full ID names such as species_id.

Data quality

What the raw files did wrong, and what caught it

Public data is not clean data. Four problems showed up, and the design handled each one on purpose.

ProblemWhat it would have doneHow it was handled
NOAA writes a failed sensor as 99.0, 999, or 9999.0, depending on the instrumentFake wave heights and temperatures that look like real numbersConverted to empty values at load. A check query confirmed zero sentinel values left in the table.
The 1998 buoy file repeats 24 hourly readings around August 27, three copies eachAugust 1998 average wind would read 6.42 m/s instead of the true 6.65, and nothing would flag itThe unique rule on station and timestamp rejected 48 duplicate rows. The load keeps one copy.
The landings CSV has a units row directly under the headerA garbage record loaded as dataSkipped in the load script and noted in the data dictionary.
8 of 60 months have landings but no buoy dataAn ordinary join drops those months silently. Totals come out 5.2 percent low with no error.Every query starts from the full list of months and uses an outer join. The gap is reported, not hidden.

Why this matters to a team: four of the six bias controls in the analysis came from the database design itself, not from extra cleanup code. When someone else runs a report next year, the protections are still there.

The questions

Five business questions, answered in SQL

The five questions below are framed the way a manager would ask them, then answered with joins, grouping, window functions, a CTE, and a set operation. Every number came from the loaded database.

QuestionAnswer
1. Which species carry the port?Pacific sardine 42.2%, market squid 29.0%, northern anchovy 17.0%. Three schooling fish carry 88% of 304,307,018 pounds.
2. Which months have landings but no buoy reading?8 months holding 15,821,764 pounds, 5.2% of everything landed. Any report on ocean effects has to say it cannot see them.
3. Did rougher seas bring in fewer pounds?The roughest quarter of months landed 22% less than the calmest. The link is weak (correlation of negative 0.23) and all rough months fall between November and March, so weather and season cannot be separated.
4. What is the full inventory of ocean readings across both instruments?112 monthly rows. The tide gauge reported all 60 months, the buoy 52. A UNION, not a join, because the tables describe different stations.
5. Which species landed in all 60 months?12, all bottom fish led by Dover sole, together just 4.9% of the pounds. The big volume comes from seasonal fish.

The query behind question 2

This is the one that shows why join choice matters. The outer join keeps every landing month, then finds the ones with no buoy match.

SELECT l.landing_month, COUNT(*) AS landing_rows,
       SUM(l.landing_pounds) AS pounds_with_no_buoy_reading
FROM landings l
INNER JOIN stations s    ON s.port_id = l.port_id
                        AND s.station_type = 'buoy'
LEFT JOIN buoy_months b  ON b.station_id = s.station_id
                        AND b.observation_month = l.landing_month
WHERE b.buoy_month_id IS NULL
GROUP BY l.landing_month
ORDER BY l.landing_month;

I also measured speed. PostgreSQL rolled all 20,460 landing rows into 341 species totals in 5.9 milliseconds, measured with EXPLAIN ANALYZE. The database carries 20 indexes.

Security

Three roles, each tested on what it must be refused

A database is only trustworthy if the wrong person cannot change it. I set up three login roles through groups, then logged in as each one and tried something it should be allowed to do and something it should not. The refusals are the proof.

RoleCanTested refusal
Analyst, for an outside research partnerRead every tablePermission denied on any change to landings
Monthly loaderAdd and correct rows in the five monthly tables, never deletePermission denied on the landings and lookup tables
AdminFull rightsNot applicable

No passwords appear in the build script. Each login gets its password set directly on the server.

The statistics

Does the catch move with the ocean? Tested four ways

I joined the three sources on month and tested the question for market squid, the species with the sharpest swings. Everything was done in Python reading the live database in a read-only session.

TestResultPlain reading
Descriptive statisticsFor all three top species, standard deviation exceeds the mean. Squid median month: 34.5 pounds against a mean of 1,473,010.A few huge months carry the average, so I report medians beside means.
El Nino year vs La Nina year (Welch's t test)t = -1.02, p = 0.33Not significant. Squid was nearly absent in both years.
All five years (ANOVA, confirmed by Kruskal-Wallis)F = 5.76, p = 0.0006The years clearly differ. Squid went from zero pounds in 1998 to 55 million in 2002.
Regression: water temperature aloneR squared 0.038, p = 0.167Temperature alone explains under 4% of monthly change.
Regression: temperature plus wave heightR squared 0.297, both slopes significantAbout 30% explained. Warmer water and higher waves went with less squid.
Scatter chart of monthly market squid landings in million pounds against monthly mean water temperature at buoy 46042, 52 months from 1998 to 2002, with a nearly flat fitted line and 21 months sitting at zero
Each dot is one month. The fitted line is nearly flat and 21 of the 52 months sit on the zero line, which is why the one-variable model is weak.

What I checked before trusting the regression

The model's assumptions failed. The residuals were not bell shaped, their spread was uneven, and each month tended to follow the one before it. The zero months are the root cause, and the model even predicts negative landings for 11 months. So I reran it with Newey-West standard errors, built for months that follow each other, and both slopes stayed significant. I also reran it without 1998, the strongest El Nino year, and the temperature slope held.

Six sources of bias, each measured

What I would and would not claim: in these five years, warmer water and higher waves went with less squid at the dock, and together they account for about 30% of the monthly change. I would not claim they caused it. Wave height is partly a stand-in for the calendar, landings measure sales and not fish in the water, and 70% of the change is unexplained.

The decision

Should the database move to NoSQL? My answer was no

The final deliverable was a recommendation to a decision-maker: move this database to a nonrelational system, or not. I made the case for moving and the case against, using this database's actual numbers, then recommended staying on PostgreSQL and adding one JSONB column to the hourly buoy table.

I also wrote the full eight-week, six-phase migration plan for the case where the decision goes the other way. It includes a validation gate of seven numbers that must match across both systems, because a migration that changes the answer has failed even when every row moved.

Honest limits

What this is, and what it is not

This is a graduate course project built on public NOAA archive data from 1998 through 2002. It is not a production system and it is not live. The data does not change, so the database is a closed archive.

One table, vessel_trips, is built but empty. It was designed for boat counts that I never downloaded. That is the missing piece for the biggest open question in the analysis: whether the squid were gone or the boats stayed in port.

The statistics describe five years and one El Nino event. They are not a forecast.

What it does show is how I work with data: design the structure around the questions, let the database enforce the rules, measure the bias before reporting a result, and say plainly what a number does not prove.

Review the work

See the build yourself

The full SQL script builds the structure, loads the data, and sets access from an empty server. It is the exact file I submitted. The NOAA sources are public, and the endpoints are listed in the table at the top of this page.

Tools:PostgreSQL 14pgAdmin 4psqlPythonpandasSciPystatsmodelsmatplotlib