diff --git a/.github/workflows/ant.yml b/.github/workflows/ant.yml index cfbcf97..fdb84de 100644 --- a/.github/workflows/ant.yml +++ b/.github/workflows/ant.yml @@ -1,4 +1,5 @@ -# This workflow will build a Java project with Ant +# This workflow builds the Java project with Ant, runs the tests, then runs the +# model headlessly and renders its real decision output onto the run summary. # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-ant name: BedDeM CI @@ -14,6 +15,13 @@ jobs: runs-on: ubuntu-latest + # Needed so the test-results step can post a check + comment on the PR. + permissions: + checks: write + pull-requests: write + issues: write + contents: read + steps: - uses: actions/checkout@v4 - name: Set up JDK 11 @@ -33,7 +41,54 @@ jobs: ant junitreport - name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action@v2 + if: always() # publish even when a test fails with: files: | junit/**/*.xml + check_name: "Test Results" + comment_mode: always # always post/update a comment on the PR + comment_title: "BedDeM test results" + - name: Upload JUnit report + uses: actions/upload-artifact@v4 + if: always() # keep the HTML/XML report even on failure + with: + name: junit-report + path: junit/ + # ---- Run the model and show its real decision output --------------------- + # The batch (headless) run drives DummyReporter, which writes + # output/decisions.csv. output/ is gitignored, so this is produced fresh in + # the pipeline on every run -- nothing is committed. + - name: Run model (batch) + run: ant batch-model "-DECLIPSE_HOME=$(pwd)" + - name: Set up Python + if: always() + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Summarise decisions + if: always() # still report (or note "no output") if the run failed + run: | + python analysis/summarize_decisions.py > decision_summary.md + cat decision_summary.md >> "$GITHUB_STEP_SUMMARY" + - name: Post decision results to PR + # Skipped automatically for PRs from forks (their token is read-only and + # cannot comment); the results still appear in the run summary + artifact. + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false + continue-on-error: true # never fail the build just over the comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: tib-decisions # one comment, updated on each run + path: decision_summary.md + - name: Render decision chart + if: always() + run: | + pip install matplotlib + python analysis/plot_tib.py + - name: Upload decision chart + if: always() + uses: actions/upload-artifact@v4 + with: + name: tib-decisions + path: analysis/tib_decisions.png + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 4345fe9..128e608 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ Repast.settings /testbin/ junit/* .idea/* + +# Generated TIB charts +analysis/*.png diff --git a/MessageCenter.log4j.properties b/MessageCenter.log4j.properties index c5cfa15..de67c67 100644 --- a/MessageCenter.log4j.properties +++ b/MessageCenter.log4j.properties @@ -44,6 +44,10 @@ log4j.logger.dummy.database.CSVReader = DEBUG, R log4j.logger.framework.agent.core.TaskExecutionAgent = DEBUG, R log4j.logger.framework.agent.reasoning.Determinant = DEBUG, R +# Per-determinant TIB scores (norm/role/self/emotion/facilitating/freq + time/cost). +# Comment this out to silence the determinant traces. +log4j.logger.dummy.agent.StandardDummyAgent = DEBUG, stdout, R + log4j.appender.R=org.apache.log4j.RollingFileAppender log4j.appender.R.File=debug.log log4j.appender.R.MaxFileSize=100KB diff --git a/README.md b/README.md index 3d54222..b71a14b 100644 --- a/README.md +++ b/README.md @@ -58,63 +58,16 @@ agentID,start_time,km,vehicle ``` This output corresponds to the `time_weight` set to 5 and `cost_weight` set to 1. -## Project structure -In this case we have example `` called `dummy`. The directory structure: -* **`src`** - - `.agent`: specific implementation of all agents for scenario and external inputs for processing - + CommunicationComponent: decision communication - + DecisionComponent: decision determinants definition - + MemoryComponent: memory definition and feelings - + PerceptionComponent: perception component of possibilities - + StandardDummyAgent: definition of the agent and its functions - - `.concept`: basic concepts used around model such as Task and Option available for agent - + EnvironmentalState: state of the environment and availability - + Feedback: feedback of the environment - + InternalState: state of the environment - + Option: environment options - + Task: one task in the agent schedule with all its constraints - + Vehicle: vehicle definition and properties - - `.context`: context definitions usually taken from the GlobVariables file - + AgentContext: context definition for agent - + LocationContext: context definition for location - - `.database`: data retrievers - + CSVReader: reader for csv files - - `.environment`: definitions of the environment - - `.report`: contains scenario specific reporter classes. - + Reporter classes: describes how what we want from the model. We can select which method to be run in Repast interface. - - `.simulator`: contain the Repast controller, scheduler and logger of the project. This is the core function of Repast and you should not modify it. The two simulator classes here are ContextManager (the simulator controller and entry points of the simulator) and ThreadAgentScheduler (schedules the Tasks and execute them). - + ContextManager: describes how the input should be read and can be modified to fit the project. Container of all the agents and locations. - - `framework.agent.core`: anything that is related to the agent's operation - + IAgent: the Interface of agent. Contains some methods that an agent should have. - + DefaultAgent: the standard agent that can be extended depend on the type of project. Its simulator operation is described in the step() method which is controlled by the Scheduler mentioned above. - + StandardTraveller: the class used specific for transportation demand. Contains the logic to evaluate an option. - - `framework.agent.reasoning`: containing the TPB and TIB model and determinant interfaces - + Determinant: standard determinant for evaluation of options - + LeafDeterminant: base determinant node in decision making - + ParentDeterminant: parent determinant in decision making model defines ranking options - + TIBModel: Triandis decision making model, definition all base determinants according to this model - + TPBModel: Theory of planned behavior model - - `framework.concept`: some basic concepts that are used around the model (i.e Task and Option available for the agent). Note that at the moment time is setup as hour and distance is in km. Also some classes that are implemented specifically for transportation demand. - + EnvironmentalState: state of environment - + Feedback: interface definition of feedback - + InternalState: interface for agent state - + Opinion: interface for agent opinion - + Option: option interface - + Task: task interface - - `framework.environment`: information of the environment where the agents reside in. (ex: available transportations, total demand, kms, spending for each mode, etc.). - + Environment: interface for location of the agent its environment - - `framework.exception`: all the exceptions and how to handle them. - -* **`data`** - - `csv_files`: two scenario example files one with `dummy` prefix and one without. Both are containing schedule for only for next day. - + agents: list of all the agent and their parameters. - + vehicle: list of all available transportation mode and their parameters. - + schedule: list of all the events to be assigned to agents. At the moment, the price point of car is added at the end of the file name (ex: 0.0 mean car_price * 0.0). This is for when we want to run in batch mode where we want to define different price points in the demand curve. - + location: list of all locations with available modes of transport - - testing: csv files for modular + system testing, will be generated if you run the testing classes. - - beddem_simulator.properties: locations of all the inputs, parameters and outputs of the model. -* **`your_project_name.rs`**: Define the inputs, outputs, parameters and observers for the model +## Scenario: Sion ↔ Sierre +The bundled scenario models a full TIB decision with **two locations** — Sion (`loc_id 1`) and Sierre (`loc_id 2`), both offering train + bus — and several agents with different attributes (weights and owned vehicles). Every TIB determinant is implemented in `StandardDummyAgent` (norm, role, self-concept, emotion, facilitating, habit, plus time/cost), each expressed as a **cost** (lower = better), so an agent picks the option with the lowest expected utility. +Everything is data-driven from `data/`: +- `agent.csv` — one row per agent: home location, funding, owned vehicles (`resources`), and the TIB weights. +- `location.csv` — `loc_id, train, bus, tram, name` (which transit each location offers). +- `schedule.0.csv` — the trips (`agent_id, start, km, time_limit, purpose`); a Sion↔Sierre trip is an 18 km row. +- `vehicle.csv` — the modes and their speed/cost. + +Add agents, locations, or trips by editing those CSVs — the `ContextManager` loads them at startup. To see each determinant's score per mode, enable `dummy.agent.StandardDummyAgent = DEBUG` in `MessageCenter.log4j.properties`. ## Tested with versions - Eclipse: 2024-03 @@ -129,5 +82,3 @@ To enable the debug logging for the BedDeM specific classes add or uncomment in ```bash log4j.logger.dummy.database.CSVReader = DEBUG, stdout, R ``` -Logger levels and the description of log4j architecture can be find [here](https://logging.apache.org/log4j/2.x/manual/architecture.html) - diff --git a/analysis/plot_tib.py b/analysis/plot_tib.py new file mode 100644 index 0000000..09e30e1 --- /dev/null +++ b/analysis/plot_tib.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Plot the model's decision results. + +Reads ``output/decisions.csv`` (written by DummyReporter during a simulation +run) and plots the expected utility (EU) of each mode per trip, with the chosen +mode highlighted. It does NOT recompute the decision -- it visualises what the +model produced. + +Usage: python analysis/plot_tib.py [agent_id] +Output: analysis/tib_decision_agent.png (one agent), or + analysis/tib_decisions.png (all agents, no arg) + +Generate the input first by running the simulation (see README) so that +output/decisions.csv exists. +""" +import csv +import os +import sys +from collections import defaultdict + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CSV = os.path.join(ROOT, "output", "decisions.csv") +OUTDIR = os.path.dirname(os.path.abspath(__file__)) +COLOR = {"car": "#2a78d6", "train": "#1baf7a", "biking": "#eda100", + "walking": "#e34948", "bus": "#eb6834", "tram": "#4a3aa7"} +LABEL = {"car": "Car", "train": "Train", "biking": "Bike", "walking": "Walk", "bus": "Bus", "tram": "Tram"} + + +def load(): + """agent -> location, and agent -> first-trip {mode: (eu, chosen)}.""" + if not os.path.exists(CSV): + print("No " + CSV + " yet -- run the simulation first (see README). Nothing to plot.") + sys.exit(0) + rows = list(csv.DictReader(open(CSV, newline=""))) + loc = {} + trips = defaultdict(lambda: defaultdict(dict)) # agent -> start_time -> mode -> (eu, chosen) + for r in rows: + loc[r["agent"]] = r["location"] + trips[r["agent"]][float(r["start_time"])][r["mode"]] = (float(r["eu"]), r["chosen"] == "1") + first = {a: t[min(t)] for a, t in trips.items()} # each agent's earliest trip + return loc, first + + +def draw(ax, agent, location, modes): + order = sorted(modes, key=lambda m: modes[m][0]) + ax.barh(range(len(order)), [modes[m][0] for m in order][::-1], + color=[COLOR.get(m, "#888") for m in order][::-1]) + ax.set_yticks(range(len(order))) + ax.set_yticklabels([LABEL.get(m, m) for m in order[::-1]], fontsize=9) + for i, m in enumerate(order[::-1]): + eu, chosen = modes[m] + ax.text(eu, i, " %.2f" % eu + (" ← chosen" if chosen else ""), va="center", fontsize=8) + ax.set_title("Agent %s (%s)" % (agent, location), fontsize=11) + ax.margins(x=0.3) + ax.set_xlabel("expected utility (lower = chosen)", fontsize=8) + + +def main(): + loc, first = load() + if len(sys.argv) > 1: + a = sys.argv[1] + if a not in first: + print("Agent %s not in %s" % (a, CSV)); sys.exit(0) + fig, ax = plt.subplots(figsize=(6, 3)) + draw(ax, a, loc[a], first[a]) + out = os.path.join(OUTDIR, "tib_decision_agent%s.png" % a) + else: + ags = sorted(first, key=lambda x: int(x)) + n = len(ags) + fig, axes = plt.subplots((n + 1) // 2, 2, figsize=(11, 2.6 * ((n + 1) // 2))) + axes = axes.ravel() + for i, a in enumerate(ags): + draw(axes[i], a, loc[a], first[a]) + for j in range(n, len(axes)): + axes[j].axis("off") + out = os.path.join(OUTDIR, "tib_decisions.png") + fig.tight_layout() + fig.savefig(out, dpi=130) + print("Wrote " + out) + + +if __name__ == "__main__": + main() diff --git a/analysis/summarize_decisions.py b/analysis/summarize_decisions.py new file mode 100644 index 0000000..7bd1dc5 --- /dev/null +++ b/analysis/summarize_decisions.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Summarise the model's decision results as Markdown (table + text bar charts). + +Reads ``output/decisions.csv`` (written by DummyReporter during a simulation +run) and prints GitHub-flavoured Markdown to stdout: an overview table plus a +per-trip Unicode bar chart of each mode's expected utility (EU, lower = better), +with the chosen mode highlighted. Text bars render inline in a PR comment and in +the workflow run summary, so no image hosting is needed. + +Usage: python analysis/summarize_decisions.py +""" +import csv +import os +import sys +from collections import defaultdict + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CSV = os.path.join(ROOT, "output", "decisions.csv") +LABEL = {"car": "Car", "train": "Train", "biking": "Bike", + "walking": "Walk", "bus": "Bus", "tram": "Tram"} +BAR_WIDTH = 24 # max characters for the longest bar in a trip + + +def load(): + rows = list(csv.DictReader(open(CSV, newline=""))) + loc = {} + # agent -> start_time -> {mode: (eu, chosen)} + trips = defaultdict(lambda: defaultdict(dict)) + for r in rows: + loc[r["agent"]] = r["location"] + trips[r["agent"]][float(r["start_time"])][r["mode"]] = ( + float(r["eu"]), r["chosen"] == "1") + return loc, trips + + +def table(loc, trips): + out = ["| Agent | Location | Trip start | Modes considered (EU) | Chosen |", + "|:-----:|:---------|:----------:|:----------------------|:------:|"] + for a in sorted(trips, key=lambda x: int(x)): + for t in sorted(trips[a]): + modes = trips[a][t] + chosen = next((m for m, (eu, c) in modes.items() if c), None) + ranked = sorted(modes.items(), key=lambda kv: kv[1][0]) + cells = [] + for m, (eu, c) in ranked: + name = LABEL.get(m, m) + cells.append("**%s %.2f**" % (name, eu) if c + else "%s %.2f" % (name, eu)) + chosen_lbl = LABEL.get(chosen, chosen) if chosen else "—" + out.append("| %s | %s | %g | %s | **%s** |" + % (a, loc[a], t, ", ".join(cells), chosen_lbl)) + return "\n".join(out) + + +def bars(loc, trips): + out = [] + for a in sorted(trips, key=lambda x: int(x)): + for t in sorted(trips[a]): + modes = trips[a][t] + ranked = sorted(modes.items(), key=lambda kv: kv[1][0]) + mx = max(eu for _, (eu, _) in ranked) or 1.0 + out.append("Agent %s (%s) — trip start %g" % (a, loc[a], t)) + for m, (eu, c) in ranked: + fill = max(1, round(eu / mx * BAR_WIDTH)) + name = LABEL.get(m, m).ljust(5) + out.append(" %s %s %.2f%s" + % (name, "█" * fill, eu, " <- chosen" if c else "")) + out.append("") + return "```\n" + "\n".join(out).rstrip() + "\n```" + + +def main(): + if not os.path.exists(CSV): + print("> **No decision output found.** `output/decisions.csv` was not " + "produced by the run — nothing to summarise.") + return + loc, trips = load() + if not trips: + print("> **`output/decisions.csv` is empty** — nothing to summarise.") + return + + print("## TIB decision results\n") + print("Expected utility per mode (**lower = chosen**), from the batch " + "run's `output/decisions.csv`.\n") + print(table(loc, trips)) + print("\n### Decision charts (EU per mode, shorter = preferred)\n") + print(bars(loc, trips)) + print("\n_A higher-resolution `tib_decisions.png` chart is attached to this " + "workflow run under **Artifacts**._") + + +if __name__ == "__main__": + main() diff --git a/data/agent.csv b/data/agent.csv index dabeb32..d9814e4 100644 --- a/data/agent.csv +++ b/data/agent.csv @@ -1,2 +1,5 @@ id,loc_id,funding,resources,time_weight,cost_weight,norm_weight,role_weight,self_weight,emotion_weight,facilitating_weight,frequency_weight,attitude_weight,social_weight,affect_weight,intention_weight,habit_weight -1,1,100,4;5,1,5,1,1,1,1,1,1,1,1,1,1,1 +1,1,100,4;5;6,1,5,1,1,1,1,1,1,1,1,1,1,1 +2,1,150,4;5,5,1,2,1,1,3,1,1,1,1,1,1,1 +3,2,90,5;6,2,1,2,5,2,1,1,1,1,1,1,1,1 +4,2,200,4,3,2,1,1,1,2,1,4,1,1,1,1,1 diff --git a/data/location.csv b/data/location.csv index 503f958..a7392d4 100644 --- a/data/location.csv +++ b/data/location.csv @@ -1,2 +1,3 @@ -loc_id,train,bus,tram -1,0,0,0 +loc_id,train,bus,tram,name +1,1,1,0,Sion +2,1,1,0,Sierre diff --git a/data/schedule.0.csv b/data/schedule.0.csv index 8e90851..a4b5155 100644 --- a/data/schedule.0.csv +++ b/data/schedule.0.csv @@ -1,3 +1,9 @@ agent_id,start,km,time_limit,purpose -1,6,2,1.2,1 -1,12,10,1.2,1 +1,8,18,2.5,2 +1,17,18,2.5,11 +2,8,18,2.5,2 +2,18,18,2.5,11 +3,7,18,3,2 +3,17,18,3,11 +4,8,18,2.5,2 +4,17,18,2.5,11 diff --git a/src/dummy/agent/DummyDecisionComponent.java b/src/dummy/agent/DummyDecisionComponent.java index eb26e17..cfb0061 100644 --- a/src/dummy/agent/DummyDecisionComponent.java +++ b/src/dummy/agent/DummyDecisionComponent.java @@ -1,11 +1,14 @@ package dummy.agent; +import java.util.HashMap; import java.util.Map; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import org.apache.log4j.Level; import org.apache.log4j.Logger; +import dummy.concept.MobilityOption; import framework.agent.core.DecisionComponent; import framework.agent.reasoning.Determinant; import framework.agent.reasoning.TIBModel; @@ -16,6 +19,10 @@ public class DummyDecisionComponent extends TIBModel implements DecisionComponen private static Logger LOGGER = Logger.getLogger(DummyDecisionComponent.class.getName()); + // Expected utility per mode for each trip, keyed by task executing time. + // Recorded so the reporter can export the model's real decision output. + private final Map> euByTime = new ConcurrentHashMap>(); + public DummyDecisionComponent(Determinant belief, Determinant evaluation, Determinant norm, Determinant role, Determinant self_concept, Determinant emotion, Determinant facilitatingCond, Determinant freq, double attitudeWeight, double socialWeight, double affectWeight, double intentionWeight, @@ -29,7 +36,21 @@ public DummyDecisionComponent(Determinant belief, Determinant evaluation, Determ public Map> evaluateOptions(Set