Skip to content

Feature/sector agnostic scenario managers#71

Open
nbarlowATI wants to merge 20 commits into
devfrom
feature/sector-agnostic-scenario-managers
Open

Feature/sector agnostic scenario managers#71
nbarlowATI wants to merge 20 commits into
devfrom
feature/sector-agnostic-scenario-managers

Conversation

@nbarlowATI

Copy link
Copy Markdown
Contributor

Changes to decouple airspace loading from scenario managers, meaning that any of the artificial airspaces can be used with any of the "Two Aircraft", "Regular", "Infinite" etc scenario managers.

Philosophy is that the scenario_category would be something like "Two Aircraft", "Infinite" etc. and the scenario_name would be the sector ("Xplus-Sector", "I-Sector", "Springfield" etc.). (Note that "Springfield" remains as a scenario_category, with the list of springfield scenarios available, unchanged.)

  • Added airspace_generator/airspace_loader.py allowing the scenario managers to load airspace, routes, and default sector_name (to be used in Coordinations) for any of I, Y, X, Xplus, TwoSector, and Springfield.
    • Scenario Managers now call AirspaceLoader.load(scenario_name) rather than having match/case blocks within their code.
  • Renamed "Tactical" scenario manager to "Custom", to better reflect its purpose.
  • Added functionality in Custom scenario manager to allow people to fully customize (deterministically) the scenario - adding fully specified aircraft and coordinations if they want to, and gave an example of doing this in the Intro-Part3.ipynb notebook.
  • Factored out some functionality that was used in more than one scenario manager (specifically code to laterally offset aircraft spawn points, and the code to actually create Aircraft and Coordinations) into utility/scenario_manager_utils.py.
  • Attempted to standardise things in Two Aircraft, Regular, Custom and Infinite scenario managers, such that they are all fully configurable via the setup method, all of them use np.random.default_rng for any random number generation, and the random seed can be set and used consistently.
  • Updated notebooks, docs, and a couple of places in bluebird-gymnasium to reflect the Tactical->Custom change.
  • Some changes to harmonise some code in sector i/x/xplus/y envs in gymnasium, particularly with regard to random seed treatment, and some common code in setting airspace metadata into envs/base.py.

@nbarlowATI
nbarlowATI requested a review from a team May 29, 2026 14:21
@nbarlowATI
nbarlowATI changed the base branch from main to dev May 29, 2026 14:22

@georgedeath georgedeath left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the substantial cleanup here. I found two functional issues that look worth fixing before merge, both around preserving explicit user intent in the new shared paths.

Comment thread bluebird-gymnasium/bluebird_gymnasium/envs/base.py Outdated
Comment thread bluebird-dt/bluebird_dt/scenario_manager/custom.py Outdated
@georgedeath

georgedeath commented May 29, 2026

Copy link
Copy Markdown
Member

Some constructor parameters are probably intentionally lower-level rather than public setup() API, so I do not think every constructor argument needs to be exposed. But from the PR description it is a little hard to tell which options are intentionally hidden and which are expected user-facing configuration. For example, Infinite.__init__ has behavior-shaping options such as automatic_outcomm, aircraft_on_route, spawn_distance_behind_fix, max_spawn_attempts, min_spawn_delta, and start_time, while Infinite.setup() exposes only part of that surface.

There is also a smaller naming consistency issue in the gym env wrappers: SectorIEnv and SectorXEnv now use category "Custom", but SectorYEnv still uses "Artificial" / "Y-Sector-Custom-Scenario", and SectorXPlusEnv still uses "Artificial". That seems to undercut the stated Tactical -> Custom cleanup even if it does not break runtime behavior.

@nbarlowATI

Copy link
Copy Markdown
Contributor Author

Thanks George - all those comments very valid+useful, and have been addressed now.

@nbarlowATI
nbarlowATI requested a review from georgedeath June 3, 2026 10:56

@AlvaroSierra AlvaroSierra left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not quite a full review, but here are some initial comments on the PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do all these notebooks always need to be run by hand if something changes? If yes should we add CI (similarly to the HMI) which checks this has been done. Otherwise, we will eventually forget (I deffinetly will 🤣).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The notebooks normally won't need to be re-run and saved after changes to the code - the changes to notebooks in this PR are just because the scenario name and categories have changed (and an automatic CI step wouldn't be able to do that for us).
We can have CI running the notebooks to test that they aren't broken though - I have added that, but it is quite slow, so for now perhaps it should only run for PRs into main...

Comment on lines +26 to +27
if category in ["Two Aircraft", "Regular", "Custom", "Infinite"]:
return ["I-Sector", "X-Sector", "Xplus-Sector", "Y-Sector", "Two Sector", "Springfield"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this list live in the AirspaceLoader type as a static method, such that it lives in the same place?

Comment on lines +192 to +193
# If we start or end outside the sector, use the `airspace.route_boundary_fixes` method
boundary_fixes = list(airspace.route_boundary_fixes(route).places.keys())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function returns the boundary of the airspace, not a specific sector, which although valid in default scenarios, it is not generalisable.

Also, a simple performance improvements:

Suggested change
# If we start or end outside the sector, use the `airspace.route_boundary_fixes` method
boundary_fixes = list(airspace.route_boundary_fixes(route).places.keys())
# If we start or end outside the sector, use the `airspace.route_boundary_fixes` method
boundary_fixes = set(airspace.route_boundary_fixes(route).places.keys())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a couple of things we could do here, with different levels of upstream change.

  • We could move the route_boundary_fixes method from Airspace to Sector, which would seem to make sense to me. However, Sector doesn't currently have any dependency on Fixes, so I'm a bit reluctant to introduce it now.
  • We could use the logic in Airspace.route_boundary_fixes directly here.

My current thinking is to do the simple part of this (i.e. see if fixes are actually on the boundary) here in this code, as it is only a couple of lines. The other part of Airspace.route_boundary_fixes, that looks for fixes near a boundary, has a comment in the code implying it needs a bit of work anyway.

Comment on lines +195 to +208
if not entry_fix:
# start from the beginning of the route
for fix in route.filed:
if fix in boundary_fixes:
entry_fix = fix
break
# if no match, revert to first fix in route
entry_fix = entry_fix if entry_fix is not None else route.filed[0]
if not exit_fix:
# start from the end of the route
for fix in reversed(route.filed):
if fix in boundary_fixes:
exit_fix = fix
break

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both of these could resolve to having the same entry and exit fix, should we fall back if this is the case?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, good point, I now check in the reverse loop that the first boundary fix encountered is not the entry fix, before assigning it as the exit fix.

if fix in boundary_fixes:
entry_fix = fix
break
# if no match, revert to first fix in route

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If no match, should we fail rather than create an unintended scenario?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so - taking first/last fix in the route is what we currently have in main/dev, so as a fallback I think it's not so bad. It is an upgrade (useful, according to Ese) to use the actual boundary fixes if we find them, but if we end up in the fallback situation we are no worse off than now.

exit_fix = fix
break
# if no match, use last fix in route
exit_fix = exit_fix if exit_fix is not None else route.filed[-1]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If no match, should we fail rather than create an unintended scenario?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above.

"""
# If airspace is not provided, fall back on first and last fixes on route
# for the entry and exit fixes
if not airspace:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In what cases would the airspace not be provided?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... good question... The airspace is only needed in this function for finding the entry and exit fixes, so I originally added it as an optional argument, without which we'd fall back to the first/last fixes on route (the current situation). But it's true that this function is unlikely to be called directly by a user, rather than by a scenario manager, so they should always have an airspace. I will make it a mandatory argument, and remove this "if" block.

Comment on lines +65 to +78
def create_aircraft_with_coordinations(
callsign: str,
pos: Pos3D,
heading: float,
speed: float,
route: Route,
sector_name: str,
entry_fl: float,
exit_fl: float,
on_route: bool = False,
airspace: Airspace | None = None,
prev_sector: str = "background",
next_sector: str = "background",
typeof_aircraft: type[TAircraft] = Aircraft,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a huge fan of having these helper functions dotted around the code, as they become something extra to maintain, is there a specific reason why this:

  1. shouldn't be a separate constructor in the types which it is building
  2. needs to be abstracted away from the callers

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree having this type of utility function is a bit messy, but not sure what the alternative is. I don't how this could be a separate constructor, given that it's returning different types (an Aircraft and two Coordinations). And the reason to abstract it away from the callers is that it's called by several scenario managers and we shouldn't duplicate the same code in all of them.
The alternative I can think of is to have it as a method in a base ScenarioManager class, so that the concrete scenario managers get it via inheritance, but then not all scenario managers (e.g. real data) would use it, so then we would maybe add another layer of inheritance (an abstract ArtificialScenarioManager or something), which would add complication.

I'm open to suggestions, but this is the least worst option I've come up with.

to_sector=sector_name,
fl=entry_fl,
fix=entry_fix,
direction="Horizontal",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should all ordinations be horizontal?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, fair point, I added this as an optional argument with default "Horizontal".

save_log_to_file=save_log_to_file,
simulated_sectors=simulated_sectors,
)
case "Custom":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we change the naming, as from the scenario name and category, it is not customisable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... true.... but I would be in favour of still calling it "Custom" so that people can still know to look at the Custom scenario manager if they want to figure out what's going on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants